diff --git a/.gitignore b/.gitignore index d0ab0575..173e152a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,4 @@ node_modules .env.* # But include .env.example files -!.env.example \ No newline at end of file +!.env.example*.tgz diff --git a/backend/app.py b/backend/app.py index 583632cd..0fe62c57 100644 --- a/backend/app.py +++ b/backend/app.py @@ -28,7 +28,7 @@ from routes.admin import admin_bp from routes.frontend import frontend_bp from routes.analytics import analytics_bp -from routes.export import export_bp +from routes.ai_assistant import ai_assistant_bp from services.db import redis_client from services.canvas_counter import get_canvas_draw_count from services.graphql_service import commit_transaction_via_graphql @@ -170,10 +170,10 @@ def handle_all_exceptions(e): app.register_blueprint(undo_redo_bp) app.register_blueprint(metrics_bp) app.register_blueprint(auth_bp) +app.register_blueprint(ai_assistant_bp) app.register_blueprint(rooms_bp) app.register_blueprint(submit_room_line_bp) app.register_blueprint(admin_bp) -app.register_blueprint(export_bp) # Register versioned API v1 blueprints for external applications from api_v1.auth import auth_v1_bp @@ -181,7 +181,6 @@ def handle_all_exceptions(e): from api_v1.collaborations import collaborations_v1_bp from api_v1.notifications import notifications_v1_bp from api_v1.users import users_v1_bp -from routes.stamps import stamps_bp from api_v1.templates import templates_v1_bp app.register_blueprint(auth_v1_bp) @@ -189,7 +188,6 @@ def handle_all_exceptions(e): app.register_blueprint(collaborations_v1_bp) app.register_blueprint(notifications_v1_bp) app.register_blueprint(users_v1_bp) -app.register_blueprint(stamps_bp, url_prefix='/api') app.register_blueprint(templates_v1_bp) # Frontend serving must be last to avoid route conflicts @@ -197,6 +195,7 @@ def handle_all_exceptions(e): app.register_blueprint(analytics_bp) if __name__ == '__main__': + print(SIGNER_PUBLIC_KEY, SIGNER_PRIVATE_KEY, RECIPIENT_PUBLIC_KEY) if not redis_client.exists('res-canvas-draw-count'): init_count = {"id": "res-canvas-draw-count", "value": 0} logger = __import__('logging').getLogger(__name__) diff --git a/backend/config.py b/backend/config.py index 0655cd6e..cb99d158 100644 --- a/backend/config.py +++ b/backend/config.py @@ -24,6 +24,7 @@ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") ANALYTICS_COLLECTION_NAME = os.getenv("ANALYTICS_COLLECTION_NAME", "analytics_events") ANALYTICS_AGGREGATES_COLLECTION = os.getenv("ANALYTICS_AGGREGATES_COLLECTION", "analytics_aggregates") +HUGGINGFACE_API_KEY=os.getenv("HUGGINGFACE_API_KEY") JWT_SECRET = os.getenv("JWT_SECRET", "dev-insecure-change-me") JWT_ISSUER = "rescanvas" diff --git a/backend/middleware/validators.py b/backend/middleware/validators.py index 2c28c3fb..caec09b4 100644 --- a/backend/middleware/validators.py +++ b/backend/middleware/validators.py @@ -487,6 +487,10 @@ def validate_stroke_payload(value) -> Tuple[bool, str]: if "pathData" not in stroke: return False, "Stroke must have pathData" + + path_data = stroke.get("pathData") + if not isinstance(path_data, (dict, list)): + return False, "Stroke pathData must be an object or array" is_valid, error = validate_color(stroke.get("color")) if not is_valid: @@ -498,6 +502,21 @@ def validate_stroke_payload(value) -> Tuple[bool, str]: return False, "Line width must be between 1 and 100" except (TypeError, ValueError): return False, "Line width must be a number" + + timestamp = stroke.get("timestamp") + if timestamp is not None: + try: + int(timestamp) + except (TypeError, ValueError): + return False, "Timestamp must be a number" + + drawing_id = stroke.get("drawingId") + if drawing_id is not None and not isinstance(drawing_id, str): + return False, "drawingId must be a string" + + stroke_id = stroke.get("id") + if stroke_id is not None and not isinstance(stroke_id, str): + return False, "id must be a string" # Validate optional signature fields (will be enforced for secure rooms in handler) signature = value.get("signature") diff --git a/backend/routes/ai_assistant.py b/backend/routes/ai_assistant.py new file mode 100644 index 00000000..058b5880 --- /dev/null +++ b/backend/routes/ai_assistant.py @@ -0,0 +1,149 @@ +from flask import Blueprint, request, jsonify +from services.llm_service import prompt_to_drawings, complete_shape_from_canvas, beautify_canvas_state +# from services.image_generation_service import ( +# text_to_image as img_text_to_image, +# ) +import logging +import base64 +import io + +ai_assistant_bp = Blueprint('ai_assistant', __name__) +logger = logging.getLogger(__name__) + + +@ai_assistant_bp.route('/api/ai_assistant/drawing', methods=['POST']) +def text_to_drawings(): + """ + Body: { "prompt": "", "canvasState": { ... } } + Returns: stroke JSON or an error payload. + """ + try: + payload = request.get_json(silent=True) or {} + prompt = payload.get("prompt") + canvas_state = payload.get("canvasState") or {} + + if not isinstance(prompt, str) or not prompt.strip(): + return jsonify({ + "error": "bad_request", + "detail": "Missing or invalid 'prompt' (string)." + }), 400 + + if not isinstance(canvas_state, dict): + return jsonify({ + "error": "bad_request", + "detail": "Invalid 'canvasState' (object)." + }), 400 + + logger.info("AI drawing requested: route entered") + logger.info("Calling prompt_to_drawings now") + result = prompt_to_drawings(prompt.strip(), canvas_state) + logger.info("prompt_to_drawings returned") + + if isinstance(result, dict) and "error" in result: + logger.warning("AI drawing failed: %s", result) + return jsonify({ + "error": "upstream_model_error", + "detail": result + }), 502 + + logger.info("AI drawing generated successfully") + logger.debug("AI drawing result: %r", result) + return jsonify(result), 200 + + except Exception as e: + logger.exception("Unhandled error in /drawing") + return jsonify({"error": "server_error", "detail": str(e)}), 500 + + +@ai_assistant_bp.route('/api/ai_assistant/complete', methods=['POST']) +def shape_completion(): + """ + Body: { "canvasState": { ... } } + Returns: { complete, confidence, object{ color, lineWidth, pathData{...} } } or an error payload. + """ + try: + payload = request.get_json(silent=True) or {} + canvas_state = payload.get("canvasState") + if not isinstance(canvas_state, dict): + return jsonify({"error": "bad_request", "detail": "Missing or invalid 'canvas_state' (object)."}), 400 + + logger.info("AI shape completion requested") + suggestion = complete_shape_from_canvas(canvas_state) + + if not isinstance(canvas_state, dict): + return jsonify({ + "error": "bad_request", + "detail": "Missing or invalid 'canvasState' (object)." + }), 400 + + return jsonify(suggestion), 200 + except Exception as e: + logger.exception("Unhandled error in /complete") + return jsonify({"error": "server_error", "detail": str(e)}), 500 + + +@ai_assistant_bp.route('/api/ai_assistant/image', methods=['POST']) +def text_to_image(): + """ + TODO: To be implemented + Body: { "prompt": "", "width"?: int, "height"?: int, "style"?: str } + Returns: { "imageDataUrl": "data:image/png;base64,..." } + """ + try: + payload = request.get_json(silent=True) or {} + prompt = payload.get("prompt", "") + width = payload.get("width") or 512 + height = payload.get("height") or 512 + style = payload.get("style") or "default" + + if not isinstance(prompt, str) or not prompt.strip(): + return jsonify({ + "error": "bad_request", + "detail": "Missing or invalid 'prompt' (string)." + }), 400 + + logger.info("AI text-to-image requested") + + pil_image = [] # img_text_to_image(prompt.strip(), width=width, height=height, style=style) + + buf = io.BytesIO() + pil_image.save(buf, format="PNG") + buf.seek(0) + encoded = base64.b64encode(buf.read()).decode("utf-8") + data_url = f"data:image/png;base64,{encoded}" + + return jsonify({"imageDataUrl": data_url}), 200 + + except Exception as e: + logger.exception("Unhandled error in /image") + return jsonify({"error": "server_error", "detail": str(e)}), 500 + + +@ai_assistant_bp.route("/api/ai_assistant/beautify", methods=["POST"]) +def beautify_sketch(): + try: + payload = request.get_json(silent=True) or {} + canvas_state = payload.get("canvasState") + + if not isinstance(canvas_state, dict): + return jsonify({ + "error": "bad_request", + "detail": "Missing or invalid 'canvasState' (object)." + }), 400 + + result = beautify_canvas_state(canvas_state) + # print("\n\ncanvas_state!!!", canvas_state, "\n\n") + # print("\n\nResult!!!", result, "\n\n") + + if not isinstance(result, dict) or "objects" not in result: + logger.warning("Beautify returned invalid payload: %r", result) + return jsonify({ + "error": "upstream_model_error", + "detail": "Beautify model returned invalid payload." + }), 502 + + return jsonify(result), 200 + + except Exception as e: + logger.exception("Unhandled error in /beautify") + return jsonify({"error": "server_error", "detail": str(e)}), 500 \ No newline at end of file diff --git a/backend/routes/rooms.py b/backend/routes/rooms.py index 510580c0..ddd71627 100644 --- a/backend/routes/rooms.py +++ b/backend/routes/rooms.py @@ -699,7 +699,10 @@ def admin_fill_wrapped_key(roomId): @require_room_access(room_id_param="roomId") @limiter.limit(f"{RATE_LIMIT_STROKE_MINUTE}/minute") @validate_request_data({ - "stroke": {"validator": lambda v: (isinstance(v, dict), "Stroke must be an object") if not isinstance(v, dict) else (True, None), "required": True}, + "stroke": { + "validator": lambda v: validate_stroke_payload(request.get_json() or {}), + "required": True + }, "signature": {"validator": validate_optional_string(max_length=1000), "required": False}, "signerPubKey": {"validator": validate_optional_string(max_length=1000), "required": False} }) @@ -732,6 +735,19 @@ def post_stroke(roomId): parent_paste_id = stroke.get("parentPasteId", "NOT SET") logger.warning(f"POST STROKE DEBUG - roomId={roomId}, brushType={brush_type}, brushParams={brush_params}, parentPasteId={parent_paste_id}") logger.warning(f"POST STROKE DEBUG - Full stroke object: {json.dumps(stroke, default=str)}") + path_data = stroke.get("pathData") + is_ai_batch_marker = isinstance(path_data, dict) and path_data.get("tool") == "paste" and path_data.get("aiGenerated") is True + is_ai_child_stroke = bool(stroke.get("parentPasteId")) or ( + isinstance(path_data, dict) and bool(path_data.get("parentPasteId")) + ) + if is_ai_batch_marker or is_ai_child_stroke: + logger.info( + "AI drawing submitted: roomId=%s drawingId=%s parentPasteId=%s batchMarker=%s", + roomId, + stroke.get("drawingId") or stroke.get("id"), + stroke.get("parentPasteId") or (path_data.get("parentPasteId") if isinstance(path_data, dict) else None), + is_ai_batch_marker, + ) except Exception as e: logger.error(f"POST STROKE DEBUG - Error logging stroke: {e}") @@ -827,6 +843,20 @@ def post_stroke(roomId): logger.warning(f"STORING FULL STROKE: {json.dumps(stroke, default=str)[:500]}...") strokes_coll.insert_one({"roomId": roomId, "ts": stroke["ts"], "stroke": stroke}) + try: + path_data = stroke.get("pathData") + if ( + stroke.get("parentPasteId") or + (isinstance(path_data, dict) and (path_data.get("parentPasteId") or path_data.get("aiGenerated"))) + ): + logger.info( + "AI drawing stored: roomId=%s strokeId=%s parentPasteId=%s", + roomId, + stroke.get("id") or stroke.get("drawingId"), + stroke.get("parentPasteId") or (path_data.get("parentPasteId") if isinstance(path_data, dict) else None), + ) + except Exception: + logger.exception("Failed to emit AI storage log for room %s", roomId) rooms_coll.update_one({"_id": room["_id"]}, {"$set": {"updatedAt": datetime.utcnow()}}) @@ -1340,6 +1370,14 @@ def get_strokes(roomId): "filterParams": stroke_data["filterParams"], } + if parent_paste_id or (isinstance(stroke_data.get("pathData"), dict) and stroke_data["pathData"].get("aiGenerated")): + logger.info( + "AI drawing restored: roomId=%s strokeId=%s parentPasteId=%s", + roomId, + stroke_id, + parent_paste_id, + ) + filtered_strokes.append(stroke_data) if stroke_id: seen_stroke_ids.add(stroke_id) diff --git a/backend/services/db.py b/backend/services/db.py index d079a74a..16c9d1dc 100644 --- a/backend/services/db.py +++ b/backend/services/db.py @@ -2,6 +2,7 @@ import threading import redis +import os from pymongo import MongoClient from pymongo.server_api import ServerApi import logging @@ -65,7 +66,22 @@ settings_coll = mongo_client[DB_NAME]["settings"] -redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0) +DISABLE_REDIS = os.getenv("DISABLE_REDIS", "false").lower() == "true" + +if DISABLE_REDIS: + try: + import fakeredis + redis_client = fakeredis.FakeStrictRedis(decode_responses=False) + print("Using fakeredis fallback (no real Redis server).") + except ImportError: + raise RuntimeError( + "DISABLE_REDIS=true but fakeredis is not installed. " + "Run: python -m pip install fakeredis" + ) +else: + REDIS_HOST = os.getenv("REDIS_HOST", "localhost") + REDIS_PORT = int(os.getenv("REDIS_PORT", 6379)) + redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0) lock = threading.Lock() diff --git a/backend/services/image_generation_service.py b/backend/services/image_generation_service.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py new file mode 100644 index 00000000..b7d68edc --- /dev/null +++ b/backend/services/llm_service.py @@ -0,0 +1,1057 @@ +# pip install openai ollama +import json +import typing +from services.llm_stroke_generation_service import generate_stroke_json + +# === text to drawings ========================================================= +# System prompt +SYSTEM_PROMPT = """ +You are a drawing-command generator for a canvas app. + +Inputs you will be given: +- CanvasState: { "drawings": [ ... ], "bounds": { "width": number, "height": number } } +- UserPrompt: a natural-language scene description + +Goal: +Return a SINGLE JSON object with an "objects" array. Each item is a canvas-ready drawing command that our app can render directly. + +Output (JSON ONLY, no comments, no markdown): +{ + "objects": [ + { + "color": "#RRGGBB", + "lineWidth": number, + "pathData": { + "tool": "shape|freehand", + "type": "rectangle|circle|line|polygon|text|stroke", + // Use one of these geometry encodings (no others): + // For circle/rectangle/line: + "start": {"x": number, "y": number}, + "end": {"x": number, "y": number}, + // For polygon (including triangles): + "points": [ {"x": number, "y": number}, ... ], + // For text: + "text": "string" + // For freehand strokes (preferred for smooth lines): + // use "tool": "freehand", "type": "stroke", + // and provide "points" as an ordered list along the stroke path. + } + } + ] +} + +Rules & Defaults (match our canvas code): +- Use ABSOLUTE pixel coordinates with (0,0) at top-left; all points MUST lie within [0, bounds.width] × [0, bounds.height]. +- Color words → hex (e.g., "red"→"#FF0000", "blue"→"#0000FF"). +- Sizes: tiny=20, small=40, medium=80, large=140, huge=220. For circles, represent size by the distance between start and end (radius as line length). +- Relative positions from the prompt (e.g., "center", "top-right") must be converted to absolute: + center=(W/2,H/2), top-left=(0,0), top=(W/2,0), top-right=(W,0), + left=(0,H/2), right=(W,H/2), bottom-left=(0,H), bottom=(W/2,H), bottom-right=(W,H). + +Style & tool selection: +- Prefer smooth, natural drawings using the freehand brush by default: + - Use "tool": "freehand" and "type": "stroke" with a "points" array that traces the stroke. +- Match the existing canvas style from CanvasState.drawings: + - If drawings are mostly geometric shapes (rectangles, circles, polygons, straight lines), + then also use mostly "shape" commands. + - If drawings are mostly strokes (drawingType === "stroke" or freehand-like paths), + then use mostly freehand strokes. + - If both are present, combine both: + - Use shapes for rigid objects (buildings, cars, roads, UI panels, etc.). + - Use freehand strokes for organic forms (trees, people, animals, clouds) and fine details. +- When following the user’s style, keep lineWidth and overall complexity visually consistent + with the existing drawings. + +Detail & realism: +- Treat each named object as something that should look like it was drawn by an expert. +- Avoid simple, undetailed blocks. Examples: + - A "city" must not just be a few plain rectangles. Use multiple buildings and add windows, + doors, and varied roof lines. + - A "building" should have at least windows and a door, plus simple roof or edge details. + - A "car" should at least show body, wheels, windows, and a hint of lights or motion. +- For complex or important objects (cities, buildings, cars, trees, faces, + people): + - Break them into several shapes and/or strokes (roughly 3–8 primitives per main object). + - Add visible details using either small shapes or short freehand strokes. +- Keep the total number of objects modest: enough to look like a clean expert sketch, + not hundreds of tiny primitives. + +When CanvasState is provided: +- Avoid obvious overlaps with existing content unless the prompt demands it (e.g., “on top of…”). +- Keep new objects visually distinct (slight offsets are OK when crowded). +- Respect the existing composition: do not cover up important existing drawings unless + the prompt says to replace or draw over something. + +Content fidelity: +- Include EVERY explicitly mentioned object; respect counts, colors, sizes, and spatial relations. +- If motion/action is described, suggest simple visual cues (e.g., angled line, small polygon “arrow”, or secondary object) using primitives or strokes. +- If ambiguous, choose a common-sense default and continue. + +Constraints: +- Output MUST be valid JSON matching the schema above. Do not include IDs (the app assigns them). +- Keep a modest number of objects (clear but not cluttered). +""" + +# Few-shot to stabilize canvas-native formatting +FEWSHOT_USER_1 = """ +CanvasState: {"drawings":[],"bounds":{"width":1800,"height":800}} +UserPrompt: draw a small blue circle at the top-right +""" + +FEWSHOT_ASSISTANT_JSON_1 = { + "objects": [ + { + "color": "#0000FF", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "circle", + "start": {"x": 2900, "y": 100}, + "end": {"x": 2940, "y": 100}, + }, + } + ] +} + +FEWSHOT_USER_2 = """ +CanvasState: +{"drawings":[],"bounds":{"width":1800,"height":800}} +UserPrompt: +"draw a red car driving in the woods" +""" + +FEWSHOT_ASSISTANT_JSON_2 = { + "objects": [ + { + "color": "#228B22", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "polygon", + "points": [ + {"x": 600, "y": 1050}, + {"x": 650, "y": 950}, + {"x": 700, "y": 1050}, + ], + }, + }, + { + "color": "#8B4513", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 645, "y": 1050}, + "end": {"x": 655, "y": 1100}, + }, + }, + { + "color": "#228B22", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "polygon", + "points": [ + {"x": 2300, "y": 1000}, + {"x": 2350, "y": 900}, + {"x": 2400, "y": 1000}, + ], + }, + }, + { + "color": "#8B4513", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 2345, "y": 1000}, + "end": {"x": 2355, "y": 1050}, + }, + }, + { + "color": "#555555", + "lineWidth": 6, + "pathData": { + "tool": "shape", + "type": "line", + "start": {"x": 400, "y": 1400}, + "end": {"x": 2600, "y": 1500}, + }, + }, + { + "color": "#FF0000", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 1450, "y": 1380}, + "end": {"x": 1650, "y": 1450}, + }, + }, + { + "color": "#FF0000", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "polygon", + "points": [ + {"x": 1500, "y": 1380}, + {"x": 1600, "y": 1380}, + {"x": 1550, "y": 1340}, + ], + }, + }, + { + "color": "#000000", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "circle", + "start": {"x": 1500, "y": 1450}, + "end": {"x": 1520, "y": 1450}, + }, + }, + { + "color": "#000000", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "circle", + "start": {"x": 1600, "y": 1450}, + "end": {"x": 1620, "y": 1450}, + }, + }, + { + "color": "#000000", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "line", + "start": {"x": 1420, "y": 1415}, + "end": {"x": 1450, "y": 1400}, + }, + }, + # Example freehand stroke for grass/detail under the car + { + "color": "#006400", + "lineWidth": 3, + "pathData": { + "tool": "freehand", + "type": "stroke", + "points": [ + {"x": 1400, "y": 1505}, + {"x": 1450, "y": 1498}, + {"x": 1500, "y": 1502}, + {"x": 1550, "y": 1496}, + {"x": 1600, "y": 1500}, + ], + }, + }, + ] +} + +FEWSHOT_USER_3 = """ +CanvasState: +{ + "drawings": [ + {"color":"#8B4513","lineWidth":2,"pathData":{"tool":"shape","type":"rectangle","start":{"x":1400,"y":1200},"end":{"x":1600,"y":1270}}}, + {"color":"#FF0000","lineWidth":2,"pathData":{"tool":"shape","type":"polygon","points":[{"x":1400,"y":1200},{"x":1500,"y":1120},{"x":1600,"y":1200}]}} + ], + "bounds":{"width":1800,"height":800} +} +UserPrompt: +"add a blue window to the right of the house" +""" + +FEWSHOT_ASSISTANT_JSON_3 = { + "objects": [ + { + "color": "#0000FF", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 1650, "y": 1210}, + "end": {"x": 1690, "y": 1245}, + }, + } + ] +} + + +def _get_text_to_drawings_initial_message( + prompt: str, canvasState: dict[str, typing.Any] +) -> list[dict]: + """ + Build the minimal, few-shot seeded chat message list for the + text→drawing JSON parser. + + Args: + prompt: The end-user natural language description (e.g., "draw a small + blue circle"). + canvasState (dict[str, Any]): + A Python dictionary representing the current state of the canvas. + + Returns: + A list of role/content dicts suitable for OpenAI/Ollama chat APIs: + [system, user(few-shot), assistant(few-shot), user(actual prompt)]. + """ + canvas_json = json.dumps(canvasState, separators=(",", ":")) + + # Combine into a single message for the model + user_prompt = ( + f"CanvasState:\n{canvas_json}\n" + f"UserPrompt:\nDescribe all drawing commands (shapes and freehand strokes) " + f"needed to draw this scene: {prompt}" + ) + + return [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": FEWSHOT_USER_1}, + {"role": "assistant", "content": json.dumps(FEWSHOT_ASSISTANT_JSON_1)}, + {"role": "user", "content": FEWSHOT_USER_2}, + {"role": "assistant", "content": json.dumps(FEWSHOT_ASSISTANT_JSON_2)}, + {"role": "user", "content": FEWSHOT_USER_3}, + {"role": "assistant", "content": json.dumps(FEWSHOT_ASSISTANT_JSON_3)}, + {"role": "user", "content": user_prompt}, + ] + + +def openai_prompt_to_json(prompt: str, canvasState: dict[str, typing.Any]) -> dict: + """ + Convert a natural-language drawing prompt into structured JSON + using the OpenAI GPT-4.1-mini model. + + Args: + prompt: The user's text prompt describing the drawing. + + Returns: + Dict containing parsed drawing attributes or an error payload. + """ + try: + from config import OPENAI_API_KEY + from openai import OpenAI + + client = OpenAI(api_key=OPENAI_API_KEY) + + resp = client.chat.completions.create( + model="gpt-4.1-mini", + response_format={"type": "json_object"}, # forces valid JSON + temperature=0.1, + messages=_get_text_to_drawings_initial_message(prompt, canvasState), + max_tokens=5000, + ) + content = resp.choices[0].message.content + return json.loads(content) + except Exception as e: + return {"error": "openai_failed", "detail": str(e)} + + +def ollama_prompt_to_json(prompt: str, canvasState: dict[str, typing.Any]) -> dict: + """ + Convert a natural-language drawing prompt into structured JSON + using a locally hosted Ollama model as a fallback. + + Args: + prompt: The user's text prompt describing the drawing. + + Returns: + Dict containing parsed drawing attributes or an error payload. + """ + try: + import ollama + + response = ollama.chat( + model="llama3:8b", + messages=_get_text_to_drawings_initial_message(prompt, canvasState), + ) + + return json.loads(response["message"]["content"]) + except Exception as e: + return {"error": "ollama_failed", "detail": str(e)} + + +def prompt_to_drawings(prompt: str, canvasState: dict[str, typing.Any]) -> dict: + try: + result = generate_stroke_json(prompt) + + items = result.get("items", []) + objects = [] + + for item in items: + path_data = {"tool": "shape", "type": item["type"]} + + if item["type"] == "polygon": + path_data["points"] = item["points"] + else: + path_data["start"] = item["start"] + path_data["end"] = item["end"] + + objects.append({ + "color": item.get("color", "#000000"), + "lineWidth": item.get("lineWidth", 4), + "pathData": path_data, + }) + + return { + "items": items, + "objects": objects, + } + except Exception as e: + return {"error": "stroke_generation_failed", "detail": str(e)} + + +# === Shape Completion ========================================================= +SHAPE_COMPLETION_SYSTEM = """ +You are a drawing intent and completion engine for a canvas app. + +You receive a CanvasState JSON object with: +- bounds: { "width": number, "height": number } +- drawings: array of drawing objects; the last one(s) are often the user's most recent strokes. + Each drawing has fields like: + - color: "#RRGGBB" + - lineWidth: number + - pathData: + For freehand strokes: + { "tool": "freehand", "type": "stroke", + "points": [ { "x": number, "y": number }, ... ] } + For geometric shapes: + { "tool": "shape", "type": "line|rectangle|circle|polygon|text", + "start": { "x": number, "y": number }, + "end": { "x": number, "y": number }, + "points": [ { "x": number, "y": number }, ... ], + "text": "optional" } + +GOAL +1. First, infer what the user is trying to draw at a higher level: + - Are they sketching a recognizable object (e.g., tree, house, car, plane, star, person, cloud)? + - Or are they just drawing an abstract or standalone geometric shape (line, rectangle, circle, polygon)? +2. Then, infer the SINGLE most likely next primitive that would continue or complete that intent, + matching the user's current drawing style: + - If their recent drawings are mainly freehand strokes: + → Predict the next stroke as a freehand stroke (tool = "freehand", type = "stroke"). + - If their recent drawings are mainly shapes: + → Predict the next geometric shape (tool = "shape"). +3. Always output ONE object that can be used as a "ghost" suggestion of what to draw next. + +OUTPUT FORMAT (JSON ONLY, no comments, no markdown): +{ + "complete": true|false, + "confidence": number, // 0.0–1.0 + "object": { + "color": "#RRGGBB", + "lineWidth": number, + "pathData": { + "tool": "shape|freehand", + "type": "line|circle|rectangle|polygon|stroke|text", + "start": { "x": number, "y": number }, + "end": { "x": number, "y": number }, + "points": [ { "x": number, "y": number }, ... ], + "text": "string" + } + } +} + +STYLE MATCHING +- Look at the LAST few drawings in CanvasState.drawings. +- If most of them use { "tool": "freehand", "type": "stroke" }, + then your suggestion must also be a freehand stroke with a "points" array. +- If most of them use { "tool": "shape", ... }, + then your suggestion must be a geometric shape (line, rectangle, circle, polygon, or text). +- Preserve the approximate lineWidth and color of the user's most recent drawing. + +SCALE & EXTENT (VERY IMPORTANT) +- Your suggestion should be a VISIBLE continuation, not a tiny jitter. +- Estimate the size of the user's most recent stroke or shape (its bounding box or start–end distance). +- For freehand strokes: + - Make the new stroke span a similar scale (roughly 50%–150% of the last stroke's span). + - Avoid strokes whose bounding box width AND height are both very small (e.g., less than ~20 pixels) + unless ALL of the user's recent strokes are that small. + - Prefer 8–30 points for a typical suggested stroke so it feels like a substantial continuation, + not just a tiny segment. +- For shapes: + - Suggested lines, rectangles, circles, or polygons should have a meaningful size as well, + comparable to the existing elements they are extending. + - Do NOT suggest micro-lines or tiny shapes unless the entire drawing is made of such tiny elements. + +SEMANTIC INTENT +- Try to recognize common objects from the partial sketch: tree, car, house, plane, star, cloud, person, etc. +- If you can infer a likely object: + - For a tree: you might add more foliage strokes, the trunk, or branches. + - For a house: you might add the roof, door, or window. + - For a car: you might add wheels, windows, or body details. +- If the sketch is too ambiguous or looks abstract: + - Focus on geometric completion: straightening or extending a line, + closing a polygon, or completing a circle/rectangle. + +GEOMETRY AND BOUNDS +- Use ABSOLUTE pixel coordinates within [0, bounds.width] × [0, bounds.height], + with (0,0) at the top-left. +- For shapes: + - line/rectangle/circle must include "start" and "end". + - polygon must include "points". +- For freehand strokes: + - Provide a "points" array with an ordered path for the stroke. + - Points should form a smooth, coherent segment that clearly continues the drawing. + +CONFIDENCE AND COMPLETENESS +- Use "confidence" to express how sure you are about the user's intent. +- If you are very unsure (confidence < 0.4): + - Set "complete": false. + - Still return your best-effort next primitive so the UI can show a light ghost suggestion. +- If the suggestion would clearly complete a part of the object (e.g., final wheel, final edge, roof line): + - You may set "complete": true for that part, even if the whole scene is not finished. + +COLOR AND WIDTH +- Default color: use the color of the user's last drawing if available; otherwise "#000000". +- Default lineWidth: match the user's last drawing's lineWidth, or use 2 if missing. + +CONSTRAINTS +- Output MUST be valid JSON and MUST match the schema above. +- Do NOT output explanations, natural language, or multiple objects. +- Always return a single best "object" that predicts the next stroke or shape. +""" + + +SHAPE_COMPLETION_FEWSHOT_USER_1 = """ +CanvasState: +{ + "drawings": [ + { + "color": "#228B22", + "lineWidth": 3, + "pathData": { + "tool": "freehand", + "type": "stroke", + "points": [ + {"x": 300, "y": 200}, + {"x": 340, "y": 180}, + {"x": 380, "y": 210}, + {"x": 360, "y": 240}, + {"x": 320, "y": 230}, + {"x": 300, "y": 200} + ] + } + } + ], + "bounds": {"width":1200,"height":800} +} +""" + +SHAPE_COMPLETION_FEWSHOT_ASSISTANT_JSON_1 = { + "complete": False, + "confidence": 0.78, + "object": { + "color": "#228B22", + "lineWidth": 3, + "pathData": { + "tool": "freehand", + "type": "stroke", + "points": [ + {"x": 340, "y": 220}, + {"x": 380, "y": 230}, + {"x": 410, "y": 210}, + {"x": 400, "y": 180}, + {"x": 370, "y": 170}, + {"x": 340, "y": 180} + ] + }, + }, +} + + +SHAPE_COMPLETION_FEWSHOT_USER_2 = """ +CanvasState: +{ + "drawings": [ + { + "color": "#8B4513", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 400, "y": 300}, + "end": {"x": 600, "y": 450} + } + }, + { + "color": "#8B0000", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "polygon", + "points": [ + {"x": 400, "y": 300}, + {"x": 500, "y": 220}, + {"x": 600, "y": 300} + ] + } + } + ], + "bounds": {"width":1200,"height":800} +} +""" + +SHAPE_COMPLETION_FEWSHOT_ASSISTANT_JSON_2 = { + "complete": False, + "confidence": 0.85, + "object": { + "color": "#654321", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 470, "y": 360}, + "end": {"x": 530, "y": 450} + }, + }, +} + +SHAPE_COMPLETION_FEWSHOT_USER_3 = """ +CanvasState: +{ + "drawings": [ + { + "color": "#FF0000", + "lineWidth": 3, + "pathData": { + "tool": "freehand", + "type": "stroke", + "points": [ + {"x": 600, "y": 500}, + {"x": 650, "y": 480}, + {"x": 720, "y": 460}, + {"x": 800, "y": 460}, + {"x": 880, "y": 480}, + {"x": 930, "y": 510} + ] + } + } + ], + "bounds": {"width":1800,"height":800} +} +""" + +SHAPE_COMPLETION_FEWSHOT_ASSISTANT_JSON_3 = { + "complete": False, + "confidence": 0.70, + "object": { + "color": "#000000", + "lineWidth": 3, + "pathData": { + "tool": "freehand", + "type": "stroke", + "points": [ + {"x": 680, "y": 510}, + {"x": 700, "y": 540}, + {"x": 730, "y": 550}, + {"x": 760, "y": 540}, + {"x": 780, "y": 510} + ] + }, + }, +} + +def _get_shape_completion_initial_message( + canvas_state: dict[str, typing.Any] +) -> list[dict]: + """ + Build the few-shot seeded chat messages for shape completion. + + Args: + canvas_state (dict[str, Any]): + The current canvas state. Expected keys: + - "drawings": list of existing drawings (color, lineWidth, pathData, etc.) + - "bounds": { "width": number, "height": number } + + Returns: + list[dict]: Chat messages for OpenAI/Ollama APIs: + [system, user(few-shot), assistant(few-shot), user(few-shot), assistant(few-shot), user(actual)] + """ + canvas_json = json.dumps(canvas_state, separators=(",", ":")) + user_msg = f"CanvasState:\n{canvas_json}" + + return [ + {"role": "system", "content": SHAPE_COMPLETION_SYSTEM}, + {"role": "user", "content": SHAPE_COMPLETION_FEWSHOT_USER_1}, + {"role": "assistant", "content": json.dumps(SHAPE_COMPLETION_FEWSHOT_ASSISTANT_JSON_1)}, + {"role": "user", "content": SHAPE_COMPLETION_FEWSHOT_USER_2}, + {"role": "assistant", "content": json.dumps(SHAPE_COMPLETION_FEWSHOT_ASSISTANT_JSON_2)}, + {"role": "user", "content": SHAPE_COMPLETION_FEWSHOT_USER_3}, + {"role": "assistant", "content": json.dumps(SHAPE_COMPLETION_FEWSHOT_ASSISTANT_JSON_3)}, + {"role": "user", "content": user_msg}, + ] + + +def openai_complete_shape(canvas_state: dict) -> dict: + """ + Infer and complete a likely shape from the current partial input using OpenAI. + + Args: + canvas_state (dict): Current canvas (drawings + bounds). + + Returns: + dict: { complete, confidence, object{ color, lineWidth, pathData{...} } } or error payload. + """ + try: + from config import OPENAI_API_KEY + from openai import OpenAI + + client = OpenAI(api_key=OPENAI_API_KEY) + + resp = client.chat.completions.create( + model="gpt-4.1-mini", + response_format={"type": "json_object"}, + temperature=0.1, + messages=_get_shape_completion_initial_message(canvas_state), + max_tokens=220, + ) + return json.loads(resp.choices[0].message.content) + except Exception as e: + return {"error": "openai_completion_failed", "detail": str(e)} + + +def ollama_complete_shape(canvas_state: dict) -> dict: + """ + Infer and complete a likely shape from the current partial input using Ollama. + + Args: + canvas_state (dict): Current canvas (drawings + bounds). + + Returns: + dict: { complete, confidence, object{ color, lineWidth, pathData{...} } } or error payload. + """ + try: + import ollama + + response = ollama.chat( + model="llama3:8b", + messages=_get_shape_completion_initial_message(canvas_state), + ) + return json.loads(response["message"]["content"]) + except Exception as e: + return {"error": "ollama_completion_failed", "detail": str(e)} + + +def complete_shape_from_canvas(canvas_state: dict) -> dict: + """ + Perform AI-based shape completion using OpenAI first, then Ollama. + + Args: + canvas_state (dict): Current canvas (drawings + bounds). + + Returns: + dict: Inferred shape completion result. + """ + model_output = openai_complete_shape(canvas_state) + if "error" not in model_output: + return model_output + return ollama_complete_shape(canvas_state) + + +# === Canvas Beautification (canvas-state) ====================== +BEAUTIFY_SYSTEM_PROMPT = """ +You are a sketch beautifier for a canvas drawing app. + +You receive a CanvasState JSON object with: +- width: number +- height: number +- objects: array of drawing objects, each with: + - id: string + - color: "#RRGGBB" + - lineWidth: number + - pathData: + For freehand strokes: + { + "tool": "freehand", + "type": "stroke", + "points": [ { "x": number, "y": number }, ... ] + } + For geometric shapes: + { + "tool": "shape", + "type": "line|rectangle|circle|polygon|text", + "start": { "x": number, "y": number }, + "end": { "x": number, "y": number }, + "points": [ { "x": number, "y": number }, ... ], + "text": "optional" + } + +GOAL +Transform the input CanvasState into a BEAUTIFIED version of the same drawing. +- Keep the overall composition, layout, and intent the same. +- Make the drawing look smoother, cleaner, and more deliberate. +- Always return your highest-quality beautification. + +OUTPUT FORMAT (JSON ONLY, no comments, no markdown): +{ + "objects": [ + { + "id": "string", + "color": "#RRGGBB", + "lineWidth": number, + "pathData": { + "tool": "shape|freehand", + "type": "line|rectangle|circle|polygon|stroke|text", + "start": { "x": number, "y": number }, + "end": { "x": number, "y": number }, + "points": [ { "x": number, "y": number }, ... ], + "text": "string" + } + }, + ... + ] +} + +BEAUTIFICATION RULES + +PRESERVE INTENT +- Do NOT change what the user is drawing: a tree must remain a tree, a car remains a car, a house remains a house, etc. +- Do NOT radically move objects: positions should remain similar; small adjustments to align or straighten are allowed. +- Keep overall proportions and relative sizes of parts (e.g., door vs house, wheels vs car body). + +STROKE SMOOTHING (FREEHAND) +- For freehand strokes (tool = "freehand", type = "stroke"): + - Remove jitter and noise; smooth the path into more confident curves and lines. + - Use a reasonable number of points: not too sparse and not excessively dense. + In general, 16–64 points per long stroke is enough. + - Ensure the stroke flows smoothly with consistent direction and curvature. + - Preserve the approximate start and end positions and overall shape of the stroke. + +GEOMETRIC CLEANUP (SHAPES) +- For lines, rectangles, circles, and polygons (tool = "shape"): + - Straighten almost-straight lines. + - Regularize rectangles so opposite sides are parallel and corners are clean. + - Regularize circles or ellipses to look smooth and round. + - Clean polygon vertices so angles look intentional, not wobbly. +- You MAY, when appropriate, upgrade a clearly intended shape drawn as a messy stroke + into a cleaner geometric shape (e.g., a wobbly "shape" polygon into a neat rectangle), + as long as the user's intent is obvious and the style of the rest of the drawing is respected. + +STYLE PRESERVATION +- Maintain the existing color palette and lineWidth relationships. +- Do NOT randomly change colors. +- Line widths can be slightly adjusted for consistency, but must feel similar to the original. +- If the whole drawing is sketchy and loose, keep a sketchy-but-clean look rather than making it fully technical or CAD-like. + +GLOBAL CONSISTENCY +- Objects that belong together (e.g., house and roof, car body and wheels, tree trunk and foliage) + should remain visually aligned and coherent after beautification. +- You may slightly align related parts (e.g., windows in a row, wheels centered vertically) if it improves cleanliness without changing the composition. + +CONSTRAINTS +- You must return a JSON object with an "objects" array using the same schema as above. +- The number of objects should usually be similar to the input; you may split or merge strokes when it clearly improves the visual quality, but do not randomly add or remove important elements. +- Do NOT output explanations, natural language, or extra fields. +- Do NOT leave the drawing partially processed: every object should be beautified as needed. +""" + +BEAUTIFY_FEWSHOT_USER_1 = """ +CanvasState: +{ + "width": 800, + "height": 600, + "objects": [ + { + "id": "stroke1", + "color": "#000000", + "lineWidth": 3, + "pathData": { + "tool": "freehand", + "type": "stroke", + "points": [ + {"x": 100, "y": 300}, + {"x": 130, "y": 295}, + {"x": 160, "y": 290}, + {"x": 190, "y": 292}, + {"x": 220, "y": 300}, + {"x": 250, "y": 310}, + {"x": 280, "y": 315} + ] + } + } + ] +} +""" + +BEAUTIFY_FEWSHOT_ASSISTANT_JSON_1 = { + "objects": [ + { + "id": "stroke1", + "color": "#000000", + "lineWidth": 3, + "pathData": { + "tool": "freehand", + "type": "stroke", + "points": [ + {"x": 100, "y": 300}, + {"x": 130, "y": 295}, + {"x": 160, "y": 292}, + {"x": 190, "y": 295}, + {"x": 220, "y": 302}, + {"x": 250, "y": 310}, + {"x": 280, "y": 315} + ] + } + } + ] +} + + +BEAUTIFY_FEWSHOT_USER_2 = """ +CanvasState: +{ + "width": 800, + "height": 600, + "objects": [ + { + "id": "rect1", + "color": "#333333", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 200, "y": 200}, + "end": {"x": 400, "y": 320} + } + } + ] +} +""" + +BEAUTIFY_FEWSHOT_ASSISTANT_JSON_2 = { + "objects": [ + { + "id": "rect1", + "color": "#333333", + "lineWidth": 2, + "pathData": { + "tool": "shape", + "type": "rectangle", + "start": {"x": 200, "y": 200}, + "end": {"x": 400, "y": 320} + } + } + ] +} + + +def _get_beautify_canvas_initial_message( + canvas_state: dict[str, typing.Any] +) -> list[dict]: + """ + Build few-shot seeded messages for beautification. + """ + canvas_json = json.dumps(canvas_state, ensure_ascii=False) + + return [ + {"role": "system", "content": BEAUTIFY_SYSTEM_PROMPT}, + {"role": "user", "content": BEAUTIFY_FEWSHOT_USER_1}, + {"role": "assistant", "content": json.dumps(BEAUTIFY_FEWSHOT_ASSISTANT_JSON_1)}, + {"role": "user", "content": BEAUTIFY_FEWSHOT_USER_2}, + {"role": "assistant", "content": json.dumps(BEAUTIFY_FEWSHOT_ASSISTANT_JSON_2)}, + {"role": "user", "content": f"CanvasState:\n{canvas_json}"}, + ] + +def openai_beautify_canvas( + canvas_state: dict[str, typing.Any], +) -> dict: + """ + Beautify the canvas using OpenAI. Returns either: + { "objects": [...] } + or + { "error": "...", "detail": "..." } + """ + try: + from config import OPENAI_API_KEY + from openai import OpenAI + + client = OpenAI(api_key=OPENAI_API_KEY) + + resp = client.chat.completions.create( + model="gpt-4.1-mini", + response_format={"type": "json_object"}, # forces JSON + temperature=0.1, + messages=_get_beautify_canvas_initial_message(canvas_state), + max_tokens=10000, + ) + + content = resp.choices[0].message.content + + print(f"\n\n{content}\n\n") + parsed = json.loads(content) + print(f"\n\n{parsed}\n\n") + + return parsed + + except Exception as e: + return {"error": "openai_beautify_failed", "detail": str(e)} + + +def ollama_beautify_canvas( + canvas_state: dict[str, typing.Any] +) -> dict: + """ + Beautify the canvas using a local Ollama model. Same contract as + openai_beautify_canvas: either { "objects": [...] } or { "error": ... }. + """ + try: + import ollama + + response = ollama.chat( + model="llama3:8b", + messages=_get_beautify_canvas_initial_message(canvas_state), + ) + + parsed = json.loads(response["message"]["content"]) + + if not isinstance(parsed, dict) or "objects" not in parsed: + return { + "error": "ollama_beautify_invalid_output", + "detail": "Missing 'objects' field in model response.", + } + + if not isinstance(parsed["objects"], list): + return { + "error": "ollama_beautify_invalid_output", + "detail": "'objects' is not a list in model response.", + } + + return parsed + + except Exception as e: + return {"error": "ollama_beautify_failed", "detail": str(e)} + + +def beautify_canvas_state( + canvas_state: dict[str, typing.Any] +) -> dict: + """ + Perform AI-based canvas beautification with rollback, following the + same pattern as prompt_to_drawings and complete_shape_from_canvas. + + Order: + 1) Try OpenAI. + 2) If that fails or returns invalid output, try Ollama. + 3) If both fail, ROLLBACK to the ORIGINAL drawings and return: + { "objects": canvas_state.drawings } + + Returns: + dict with at least: + { "objects": [...] } + """ + # Primary: OpenAI + model_output = openai_beautify_canvas(canvas_state) + if "error" not in model_output and "objects" in model_output: + return model_output + + print(f"\n\nFAILED OPENAI API!: {model_output} \n\n") + + # Fallback: Ollama + fallback_output = ollama_beautify_canvas(canvas_state) + if "error" not in fallback_output and "objects" in fallback_output: + return fallback_output + + # Rollback: both failed => return original drawings as objects + original_drawings = canvas_state.get("objects", []) + return {"objects": original_drawings} diff --git a/backend/services/llm_stroke_generation_service.py b/backend/services/llm_stroke_generation_service.py new file mode 100644 index 00000000..e670dea2 --- /dev/null +++ b/backend/services/llm_stroke_generation_service.py @@ -0,0 +1,125 @@ +import json +from typing import Dict, Any + +from config import OPENAI_API_KEY + + +SYSTEM_PROMPT = """ +You are a drawing assistant that outputs ONLY valid JSON. + +You must convert user requests into simple drawing instructions. + +Rules: +- Output JSON ONLY (no explanation) +- Use this schema: + +{ + "items": [ + { + "type": "line | rectangle | circle | polygon", + "start": {"x": int, "y": int}, + "end": {"x": int, "y": int}, + "points": [{"x": int, "y": int}] + } + ] +} + +Constraints: +- Coordinates must be between 0 and 512 +- Keep drawings SIMPLE +- Prefer rectangles + lines +- DO NOT include text or comments +""" + + +def _validate_point(p: Dict[str, Any]) -> None: + if not isinstance(p, dict): + raise ValueError("Point must be an object") + if "x" not in p or "y" not in p: + raise ValueError("Point must include x and y") + if not isinstance(p["x"], (int, float)) or not isinstance(p["y"], (int, float)): + raise ValueError("Point coordinates must be numeric") + if not (0 <= p["x"] <= 512 and 0 <= p["y"] <= 512): + raise ValueError(f"Point out of bounds: {p}") + + +def _validate_output(data: Dict[str, Any]) -> Dict[str, Any]: + if "items" not in data or not isinstance(data["items"], list): + raise ValueError("Invalid format: missing 'items' list") + + for item in data["items"]: + if not isinstance(item, dict): + raise ValueError("Each item must be an object") + + if "type" not in item: + raise ValueError("Missing type in item") + + t = item["type"] + if t not in {"line", "rectangle", "circle", "polygon"}: + raise ValueError(f"Unsupported type: {t}") + + if t == "polygon": + if "points" not in item or not isinstance(item["points"], list) or len(item["points"]) < 3: + raise ValueError("Polygon must have >= 3 points") + for p in item["points"]: + _validate_point(p) + else: + if "start" not in item or "end" not in item: + raise ValueError(f"{t} must have start and end") + _validate_point(item["start"]) + _validate_point(item["end"]) + + return data + + +def _strip_code_fences(content: str) -> str: + content = content.strip() + if content.startswith("```"): + lines = content.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].startswith("```"): + lines = lines[:-1] + if lines and lines[0].strip().lower() == "json": + lines = lines[1:] + content = "\n".join(lines).strip() + return content + + +def generate_stroke_json(prompt: str) -> Dict[str, Any]: + """ + Convert text prompt -> structured stroke JSON + """ + if not prompt or not prompt.strip(): + raise ValueError("Prompt cannot be empty") + + if not OPENAI_API_KEY: + raise RuntimeError("OPENAI_API_KEY is missing") + + try: + from openai import OpenAI + + client = OpenAI(api_key=OPENAI_API_KEY, timeout=20.0) + + response = client.chat.completions.create( + model="gpt-4o-mini", + temperature=0.2, + response_format={"type": "json_object"}, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt.strip()}, + ], + ) + + content = response.choices[0].message.content.strip() + content = _strip_code_fences(content) + + try: + data = json.loads(content) + except json.JSONDecodeError: + raise RuntimeError(f"Model returned invalid JSON:\n{content}") + + return _validate_output(data) + + except Exception as e: + raise RuntimeError(f"LLM stroke generation failed: {e}") \ No newline at end of file diff --git a/backend/tests/integration/test_strokes_api.py b/backend/tests/integration/test_strokes_api.py index 12599d85..44422f4b 100644 --- a/backend/tests/integration/test_strokes_api.py +++ b/backend/tests/integration/test_strokes_api.py @@ -99,9 +99,10 @@ def test_submit_stroke_with_invalid_data(self, client, auth_headers, test_room, json={'stroke': invalid_stroke}, headers=auth_headers) - # Backend accepts any dict as stroke and processes it (returns 200) - # The validation only checks that stroke is a dict, not its contents - assert response.status_code == 200 + assert response.status_code == 400 + payload = response.get_json() + assert payload["status"] == "error" + assert payload["code"] == "VALIDATION_ERROR" def test_submit_stroke_private_room_encrypted(self, client, mock_mongodb, mock_redis, auth_headers, private_room, mock_graphql_service): room_id = str(private_room["_id"]) diff --git a/backend/tests/test_ai_generated_drawings.py b/backend/tests/test_ai_generated_drawings.py new file mode 100644 index 00000000..970763c4 --- /dev/null +++ b/backend/tests/test_ai_generated_drawings.py @@ -0,0 +1,76 @@ +import time + + +def make_ai_stroke(drawing_id, parent_paste_id=None): + path_data = { + "tool": "shape", + "type": "rectangle", + "start": {"x": 10, "y": 10}, + "end": {"x": 40, "y": 40}, + } + if parent_paste_id: + path_data["parentPasteId"] = parent_paste_id + + stroke = { + "drawingId": drawing_id, + "color": "#000000", + "lineWidth": 4, + "pathData": path_data, + "timestamp": int(time.time() * 1000), + } + if parent_paste_id: + stroke["parentPasteId"] = parent_paste_id + return {"stroke": stroke} + + +def test_ai_generated_drawings_are_grouped_for_undo_and_redo(client, test_room, auth_headers): + room_id = str(test_room["_id"]) + batch_id = "ai_batch_1" + + for drawing_id in ("ai_child_1", "ai_child_2"): + payload = make_ai_stroke(drawing_id, parent_paste_id=batch_id) + response = client.post( + f"/rooms/{room_id}/strokes", + json={**payload, "skipUndoStack": True}, + headers=auth_headers, + ) + assert response.status_code in (200, 201), response.get_json() + + batch_marker = { + "stroke": { + "drawingId": batch_id, + "color": "#FFFFFF", + "lineWidth": 1, + "pathData": { + "tool": "paste", + "cut": False, + "pastedDrawingIds": ["ai_child_1", "ai_child_2"], + "aiGenerated": True, + }, + "timestamp": int(time.time() * 1000), + } + } + response = client.post(f"/rooms/{room_id}/strokes", json=batch_marker, headers=auth_headers) + assert response.status_code in (200, 201), response.get_json() + + strokes = client.get(f"/rooms/{room_id}/strokes", headers=auth_headers).get_json()["strokes"] + ids = {s.get("drawingId") or s.get("id") for s in strokes} + assert {"ai_child_1", "ai_child_2", batch_id}.issubset(ids) + + undo_response = client.post(f"/rooms/{room_id}/undo", headers=auth_headers) + assert undo_response.status_code == 200, undo_response.get_json() + assert undo_response.get_json()["status"] == "ok" + + strokes_after_undo = client.get(f"/rooms/{room_id}/strokes", headers=auth_headers).get_json()["strokes"] + ids_after_undo = {s.get("drawingId") or s.get("id") for s in strokes_after_undo} + assert "ai_child_1" not in ids_after_undo + assert "ai_child_2" not in ids_after_undo + assert batch_id not in ids_after_undo + + redo_response = client.post(f"/rooms/{room_id}/redo", headers=auth_headers) + assert redo_response.status_code == 200, redo_response.get_json() + assert redo_response.get_json()["status"] == "ok" + + strokes_after_redo = client.get(f"/rooms/{room_id}/strokes", headers=auth_headers).get_json()["strokes"] + ids_after_redo = {s.get("drawingId") or s.get("id") for s in strokes_after_redo} + assert {"ai_child_1", "ai_child_2", batch_id}.issubset(ids_after_redo) diff --git a/frontend/src/components/AI/AIAssistantPanel.jsx b/frontend/src/components/AI/AIAssistantPanel.jsx new file mode 100644 index 00000000..ef3f9f69 --- /dev/null +++ b/frontend/src/components/AI/AIAssistantPanel.jsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import '../../styles/ai-assistant.css'; +import { Tooltip, IconButton } from "@mui/material"; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import ImageIcon from '@mui/icons-material/Image'; +import RoundedCornerIcon from '@mui/icons-material/RoundedCorner'; +import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; +import { useAIAssistant } from "../../hooks/useAIAssistant"; + +export default function AIAssistantPanel({ + open, + showPromptInput, + onShapeCompletionToggle, + getBeautifyCanvasState, + clearCanvas, + showLocalSnack, + addAIGeneratedObjects +}) { + const [activeButton, setActiveButton] = useState(""); + const { aiAssistLoading, beautifySketch } = useAIAssistant(); + + const handleBeautify = async () => { + if (aiAssistLoading) return; + + try { + const canvasState = getBeautifyCanvasState(); + const result = await beautifySketch(canvasState); + + if (!result || !Array.isArray(result.objects) || result.objects.length === 0) { + showLocalSnack("Beautify failed. Please try again."); + return; + } + + const beautifiedObjects = result.objects; + + // CLear canvas before rendering beatutified version + clearCanvas(); + + // Add beatified version to the canvas + await addAIGeneratedObjects(beautifiedObjects); + + showLocalSnack("Sketch beautified"); + } catch (err) { + showLocalSnack("Beautify error"); + console.error(err); + } + }; + + + const handlePanelItemClick = async (itemTitle) => { + if (itemTitle === "Beautify sketch") { + await handleBeautify() + return; + } + + const next = activeButton === itemTitle ? "" : itemTitle; + setActiveButton(next); + + if (itemTitle === "Shape auto completion") { + if (typeof onShapeCompletionToggle === "function") { + onShapeCompletionToggle(next === "Shape auto completion"); + } + return; + } + + if (next === "Generate sketch") { + showPromptInput(true, { + type: 'drawing', + placeholder: "Describe what to draw…" + }); + } else if (next === "Generate image") { + showPromptInput(true, { + type: 'image', + placeholder: "Describe the image to generate…" + }); + } else { + showPromptInput(false, { + type: '', + placeholder: "" + }); + } + }; + + const renderStyleClass = (itemTitle) => { + if (itemTitle === "Beautify sketch") { + return "ai-asisstant-panel-item"; + } + + return itemTitle === activeButton + ? "ai-asisstant-panel-item ai-asisstant-panel-item-active" + : "ai-asisstant-panel-item"; + }; + + return ( +
+ +
handlePanelItemClick("Generate sketch")} + aria-pressed={activeButton === "Generate sketch"} + > + + + + + +
+ +
handlePanelItemClick("Generate image")} + aria-pressed={activeButton === "Generate image"} + > + + + + + +
+ +
handlePanelItemClick("Shape auto completion")} + aria-pressed={activeButton === "Shape auto completion"} + > + + + + + +
+ +
handlePanelItemClick("Beautify sketch")} + aria-pressed={false} + > + + + + + +
+
+ ); +} diff --git a/frontend/src/components/AI/PromptInput.jsx b/frontend/src/components/AI/PromptInput.jsx new file mode 100644 index 00000000..0f0be82a --- /dev/null +++ b/frontend/src/components/AI/PromptInput.jsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { Button, TextareaAutosize, Box, CircularProgress } from '@mui/material'; +import { useAIAssistant } from '../../hooks/useAIAssistant'; + + +export default function PromptInput({ + show = false, + placeholder = 'Describe what to draw…', + getVisibleCanvasBounds, + addAIGeneratedObjects, + showLocalSnack, +}) { + + const { textToDrawing, aiAssistLoading } = useAIAssistant(); + const [text, setText] = useState(''); + + const handleSubmit = async () => { + if (!text.trim() || aiAssistLoading) return; + + try { + const canvasState = getVisibleCanvasBounds?.() || {}; + console.info("[AI] prompt submitted", { + promptLength: text.trim().length, + hasCanvasState: Object.keys(canvasState).length > 0, + }); + const resp = await textToDrawing(text.trim(), canvasState); + const payload = typeof resp === "string" ? JSON.parse(resp) : resp; + + // New event-driven path + if (payload && Array.isArray(payload.items)) { + console.info("[AI] drawing payload received", { + itemCount: payload.items.length, + }); + window.dispatchEvent( + new CustomEvent("rescanvas:ai-drawing-generated", { + detail: payload, + }) + ); + + showLocalSnack("AI objects rendered to canvas."); + setText(""); + return; + } + + // Backward-compatible fallback + if (payload && Array.isArray(payload.objects) && addAIGeneratedObjects) { + await addAIGeneratedObjects(payload.objects); + showLocalSnack("AI objects rendered to canvas."); + setText(""); + return; + } + + console.error("Unexpected AI payload:", payload); + showLocalSnack("An error occurred while generating the sketch."); + } catch (e) { + console.error(e); + showLocalSnack("Failed to generate sketch."); + } +}; + + const handleKeyDownPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setText(''); + } + }; + + return ( + + setText(e.target.value)} + onKeyDown={handleKeyDownPress} + disabled={aiAssistLoading} + minRows={1} + style={{ + width: 'auto', + minWidth: 450, + resize: 'none', + border: 'none', + outline: 'none', + fontSize: '14px', + padding: '8px', + background: 'transparent', + fontFamily: 'inherit', + }} + /> + + + ); +} diff --git a/frontend/src/components/AI/ShapeCompletionOverlay.jsx b/frontend/src/components/AI/ShapeCompletionOverlay.jsx new file mode 100644 index 00000000..ce7b3b98 --- /dev/null +++ b/frontend/src/components/AI/ShapeCompletionOverlay.jsx @@ -0,0 +1,303 @@ +import { useEffect, useState } from 'react'; +import { Box, IconButton } from '@mui/material'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import { useAIAssistant } from '../../hooks/useAIAssistant'; + +export default function ShapeCompletionOverlay({ + enabled = false, + trigger = 0, + userData, + pendingDrawings, + editingEnabled, + canvasWidth, + canvasHeight, + panOffset = { x: 0, y: 0 }, + getVisibleCanvasBounds, + addAIGeneratedObjects = () => {}, + showLocalSnack = () => {}, +}) { + const { shapeCompletion } = useAIAssistant(); + + const [suggestion, setSuggestion] = useState(null); + const [anchor, setAnchor] = useState(null); + + // Finds where in the drawings to place the suggestion + const computeSuggestionAnchor = (pathData) => { + if (Array.isArray(pathData?.points) && pathData.points.length > 0) { + const xs = pathData.points.map((p) => p.x); + const ys = pathData.points.map((p) => p.y); + return { + x: (Math.min(...xs) + Math.max(...xs)) / 2, + y: (Math.min(...ys) + Math.max(...ys)) / 2, + }; + } + + if (pathData?.start && pathData?.end) { + return { + x: (pathData.start.x + pathData.end.x) / 2, + y: (pathData.start.y + pathData.end.y) / 2, + }; + } + + return { x: canvasWidth / 2, y: canvasHeight / 2 }; + }; + + useEffect(() => { + if (!enabled) return; + if (!editingEnabled) { + showLocalSnack('Shape completion is disabled in view-only mode.'); + return; + } + + const handleCompletion = async () => { + try { + const bounds = getVisibleCanvasBounds(); + const canvasState = { + drawings: [ + ...(userData?.drawings || []), + ...(pendingDrawings || []), + ], + bounds: { + width: bounds?.width || canvasWidth, + height: bounds?.height || canvasHeight, + }, + }; + + // API Call for completion + const s = await shapeCompletion(canvasState); + if (!s || s.error || !s.object) { + showLocalSnack('AI could not infer a shape.'); + setSuggestion(null); + setAnchor(null); + return; + } + + const { pathData } = s.object || {}; + const a = computeSuggestionAnchor(pathData); + setSuggestion(s); + setAnchor(a); + } catch (e) { + console.error('Shape completion error:', e); + showLocalSnack('Unexpected error during shape completion.'); + setSuggestion(null); + setAnchor(null); + } + }; + + if (trigger > 0) { + handleCompletion(); + } + }, [ + trigger, + enabled, + ]); + + useEffect(() => { + if (!enabled) { + setSuggestion(null); + setAnchor(null); + } + }, [enabled]); + + const handleAccept = async () => { + if (!suggestion?.object) return; + await addAIGeneratedObjects([suggestion.object]); + setSuggestion(null); + setAnchor(null); + }; + + const handleReject = () => { + setSuggestion(null); + setAnchor(null); + }; + + if ( + !enabled || + !suggestion || + !suggestion.object || + !suggestion.object.pathData + ) { + return null; + } + + const { object } = suggestion; + const { pathData } = object; + + const strokeColor = object.color || '#00A0FF'; + const strokeWidth = object.lineWidth || 2; + const ghostOpacity = 0.25; + + const ax = (anchor?.x ?? canvasWidth / 2) + panOffset.x; + const ay = (anchor?.y ?? canvasHeight / 2) + panOffset.y; + + const renderShape = () => { + const t = pathData.type; + const tool = pathData.tool || 'shape'; + + if ( + tool === 'freehand' && + t === 'stroke' && + Array.isArray(pathData.points) && + pathData.points.length > 1 + ) { + const pointsAttr = pathData.points.map((p) => `${p.x},${p.y}`).join(' '); + return ( + + ); + } + + if (['line', 'circle', 'rectangle'].includes(t) && pathData.start && pathData.end) { + const { start, end } = pathData; + + if (t === 'line') { + return ( + + ); + } + + if (t === 'circle') { + const cx = (start.x + end.x) / 2; + const cy = (start.y + end.y) / 2; + const dx = end.x - start.x; + const dy = end.y - start.y; + const r = Math.sqrt(dx * dx + dy * dy); + + return ( + + ); + } + + if (t === 'rectangle') { + const x = Math.min(start.x, end.x); + const y = Math.min(start.y, end.y); + const w = Math.abs(end.x - start.x); + const h = Math.abs(end.y - start.y); + + return ( + + ); + } + } + + if (t === 'polygon' && Array.isArray(pathData.points) && pathData.points.length > 1) { + const pointsAttr = pathData.points.map((p) => `${p.x},${p.y}`).join(' '); + return ( + + ); + } + + if (t === 'text' && typeof pathData.text === 'string' && pathData.start) { + return ( + + {pathData.text} + + ); + } + + return null; + }; + + return ( + <> + + {renderShape()} + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/Canvas.js b/frontend/src/components/Canvas.js index c08dbbeb..70e8e36f 100644 --- a/frontend/src/components/Canvas.js +++ b/frontend/src/components/Canvas.js @@ -1,4454 +1,4794 @@ -import React, { useRef, useState, useEffect } from "react"; -import "../styles/Canvas.css"; -import { - Box, - Fade, - Paper, - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - IconButton, - TextField, - Typography, - CircularProgress, -} from '@mui/material'; -import SafeSnackbar from './SafeSnackbar'; -import CommandPalette from './CommandPalette'; -import KeyboardShortcutsHelp from './KeyboardShortcutsHelp'; -import { KeyboardShortcutManager } from '../services/KeyboardShortcuts'; -import { commandRegistry } from '../services/CommandRegistry'; -import { DEFAULT_SHORTCUTS } from '../config/shortcuts'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import Toolbar from './Toolbar'; -import { useCanvasSelection } from '../hooks/useCanvasSelection'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import useBrushEngine from "../hooks/useBrushEngine"; -import { - submitToDatabase, - refreshCanvas as backendRefreshCanvas, - clearBackendCanvas, - undoAction, - redoAction, - checkUndoRedoAvailability -} from '../services/canvasBackendJWT'; -import { Drawing } from '../lib/drawing'; -import { getSocket, setSocketToken } from '../services/socket'; -import { handleAuthError } from '../utils/authUtils'; -import { getUsername } from '../utils/getUsername'; -import { getAuthUser } from '../utils/getAuthUser'; -import { resetMyStacks } from '../api/rooms'; -import { TEMPLATE_LIBRARY } from '../data/templates'; - -class UserData { - constructor(userId, username) { - this.userId = userId; - this.username = username; - this.drawings = []; - } - - addDrawing(drawing) { - this.drawings.push(drawing); - } - - clearDrawings() { - this.drawings = []; - } -} - -const DEFAULT_CANVAS_WIDTH = 3000; -const DEFAULT_CANVAS_HEIGHT = 2000; - -function Canvas({ - auth, - setUserList, - selectedUser, - setSelectedUser, - currentRoomId, - canvasRefreshTrigger = 0, - currentRoomName = "Master (not in a room)", - onExitRoom = () => { }, - onOpenSettings = null, - viewOnly = false, - isOwner = false, - roomType = "public", - walletConnected = false, - templateId = null, -}) { - const canvasRef = useRef(null); - const snapshotRef = useRef(null); - const tempPathRef = useRef([]); - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - - const currentUserRef = useRef(null); - if (currentUserRef.current === null) { +import React, { useRef, useState, useEffect } from "react"; +import "../styles/Canvas.css"; +import { + Box, + Fade, + Paper, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + TextField, + Typography, + CircularProgress, +} from '@mui/material'; +import SafeSnackbar from './SafeSnackbar'; +import CommandPalette from './CommandPalette'; +import KeyboardShortcutsHelp from './KeyboardShortcutsHelp'; +import { KeyboardShortcutManager } from '../services/KeyboardShortcuts'; +import { commandRegistry } from '../services/CommandRegistry'; +import { DEFAULT_SHORTCUTS } from '../config/shortcuts'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import Toolbar from './Toolbar'; +import { aiPayloadToDrawings } from "../utils/aiStrokeAdapter"; + +// AI Assist Imports +import AIAssistantPanel from './AI/AIAssistantPanel'; +import PromptInput from './AI/PromptInput'; +import ShapeCompletionOverlay from './AI/ShapeCompletionOverlay'; +// AI Assist Hook +import { useAIAssistant } from '../hooks/useAIAssistant'; + +import { useCanvasSelection } from '../hooks/useCanvasSelection'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import useBrushEngine from "../hooks/useBrushEngine"; +import { + submitToDatabase, + refreshCanvas as backendRefreshCanvas, + clearBackendCanvas, + undoAction, + redoAction, + checkUndoRedoAvailability +} from '../services/canvasBackendJWT'; +import { Drawing } from '../lib/drawing'; +import { getSocket, setSocketToken } from '../services/socket'; +import { handleAuthError } from '../utils/authUtils'; +import { getUsername } from '../utils/getUsername'; +import { getAuthUser } from '../utils/getAuthUser'; +import { resetMyStacks } from '../api/rooms'; +import { TEMPLATE_LIBRARY } from '../data/templates'; + +class UserData { + constructor(userId, username) { + this.userId = userId; + this.username = username; + this.drawings = []; + } + + addDrawing(drawing) { + this.drawings.push(drawing); + } + + clearDrawings() { + this.drawings = []; + } +} + +const DEFAULT_CANVAS_WIDTH = 3000; +const DEFAULT_CANVAS_HEIGHT = 2000; + +function Canvas({ + auth, + setUserList, + selectedUser, + setSelectedUser, + currentRoomId, + canvasRefreshTrigger = 0, + currentRoomName = "Master (not in a room)", + onExitRoom = () => { }, + onOpenSettings = null, + viewOnly = false, + isOwner = false, + roomType = "public", + walletConnected = false, + templateId = null, +}) { + const canvasRef = useRef(null); + const snapshotRef = useRef(null); + const tempPathRef = useRef([]); + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + + const currentUserRef = useRef(null); + if (currentUserRef.current === null) { + try { + const uname = + getUsername(auth) || + `anon_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; + currentUserRef.current = `${uname}|${Date.now()}`; + } catch (e) { + currentUserRef.current = `anon_${Date.now()}_${Math.random() + .toString(36) + .substr(2, 5)}`; + } + } + const currentUser = currentUserRef.current; + + const [drawing, setDrawing] = useState(false); + const [color, setColor] = useState("#000000"); + const [lineWidth, setLineWidth] = useState(5); + const [drawMode, setDrawMode] = useState("freehand"); + const [shapeType, setShapeType] = useState("circle"); + const [brushStyle] = useState("round"); + const [shapeStart, setShapeStart] = useState(null); + + const brushEngine = useBrushEngine(); + const [currentBrushType, setCurrentBrushType] = useState("normal"); + const [brushParams, setBrushParams] = useState({}); + const [selectedStamp, setSelectedStamp] = useState(null); + const [stampSettings, setStampSettings] = useState({ + size: 50, + rotation: 0, + opacity: 100, + }); + const [backendStamps, setBackendStamps] = useState([]); + const [stampPreview, setStampPreview] = useState(null); // { x, y, stamp, settings } + const stampPreviewRef = useRef(null); + const [activeFilter, setActiveFilter] = useState(null); + const [filterParams, setFilterParams] = useState({}); + const [isFilterPreview, setIsFilterPreview] = useState(false); + const filterCanvasRef = useRef(null); + const originalCanvasDataRef = useRef(null); // For preview mode undo + const preFilterCanvasStateRef = useRef(null); + + const [showColorPicker, setShowColorPicker] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [previousColor, setPreviousColor] = useState(null); + const [clearDialogOpen, setClearDialogOpen] = useState(false); + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + const [undoAvailable, setUndoAvailable] = useState(false); + const [redoAvailable, setRedoAvailable] = useState(false); + const [hasFilters, setHasFilters] = useState(false); // Track if filters exist for UI updates + + // AI Asiistant Panel States + const [aiOpen, setAiOpen] = useState(false); + const [showPromptInput, setShowPromptInput] = useState(false); + const [shapeCompletionTrigger, setShapeCompletionTrigger] = useState(0); + const [shapeCompletionEnabled, setShapeCompletionEnabled] = useState(false); + + const [templateObjects, setTemplateObjects] = useState([]); + const templateObjectsRef = useRef([]); + + useEffect(() => { + templateObjectsRef.current = templateObjects; + }, [templateObjects]); + + const canvasWidth = DEFAULT_CANVAS_WIDTH; + const canvasHeight = DEFAULT_CANVAS_HEIGHT; + + const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); + const [isPanning, setIsPanning] = useState(false); + const panStartRef = useRef({ x: 0, y: 0 }); + const panOriginRef = useRef({ x: 0, y: 0 }); + const PAN_REFRESH_COOLDOWN_MS = 2000; + const panLastRefreshRef = useRef(0); + const panRefreshSkippedRef = useRef(false); + const panEndRefreshTimerRef = useRef(null); + const pendingPanRefreshRef = useRef(false); + const [pendingDrawings, setPendingDrawings] = useState([]); + const refreshTimerRef = useRef(null); + const submissionQueueRef = useRef([]); + const isSubmittingRef = useRef(false); + const confirmedStrokesRef = useRef(new Set()); + const lastDrawnStateRef = useRef(null); // Track last drawn state to avoid redundant redraws + const isDrawingInProgressRef = useRef(false); // Prevent concurrent drawing operations + const offscreenCanvasRef = useRef(null); // Offscreen canvas for flicker free rendering + const forceNextRedrawRef = useRef(false); // Force next redraw even if signature matches for undo redo + const [historyMode, setHistoryMode] = useState(false); + const [historyRange, setHistoryRange] = useState(null); // {start, end} in epoch ms + const [historyDialogOpen, setHistoryDialogOpen] = useState(false); + const [historyStartInput, setHistoryStartInput] = useState(""); + const [historyEndInput, setHistoryEndInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const [localSnack, setLocalSnack] = useState({ + open: false, + message: "", + duration: 4000, + }); + const [confirmDestructiveOpen, setConfirmDestructiveOpen] = useState(false); + const [destructiveConfirmText, setDestructiveConfirmText] = useState(""); + const showLocalSnack = (msg, duration = 4000) => + setLocalSnack({ open: true, message: String(msg), duration }); + + // editingEnabled controls whether the user can perform mutating actions. + // When historyMode is active, a specific user is selected for replay, or + // when viewOnly is true (room is archived or user is a viewer), editing + // should be disabled. + // For secure rooms, wallet must be connected to allow editing. + const editingEnabled = !( + historyMode || + (selectedUser && selectedUser !== "") || + viewOnly || + (roomType === "secure" && !walletConnected) + ); + const closeLocalSnack = () => + setLocalSnack({ open: false, message: "", duration: 4000 }); + + // Keyboard shortcuts state + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false); + const shortcutManagerRef = useRef(null); + + const roomUiRef = useRef({}); + const previousSelectedUserRef = useRef(null); // Track previous selectedUser to detect changes + const isRefreshingSelectedUserRef = useRef(false); // Prevent concurrent refreshes + const selectedUserRefreshQueueRef = useRef(null); // Queue the next refresh target + const selectedUserAbortControllerRef = useRef(null); // Cancel pending operations + const roomStacksRef = useRef({}); + const roomClipboardRef = useRef({}); + const roomClearedAtRef = useRef({}); + const drawAllDrawingsRef = useRef(null); // Store reference to drawAllDrawings function + + useEffect(() => { + if (!currentRoomId) return; + const ui = + roomUiRef.current[currentRoomId] || + JSON.parse( + localStorage.getItem(`rescanvas:toolbar:${currentRoomId}`) || "null" + ) || + {}; + if (ui.color) setColor(ui.color); + if (ui.lineWidth) setLineWidth(ui.lineWidth); + if (ui.drawMode) setDrawMode(ui.drawMode); + if (ui.shapeType) setShapeType(ui.shapeType); + if (ui.previousColor !== undefined) setPreviousColor(ui.previousColor); + if (ui.selectedStamp) setSelectedStamp(ui.selectedStamp); + if (ui.stampSettings) setStampSettings(ui.stampSettings); + if (ui.currentBrushType) { + setCurrentBrushType(ui.currentBrushType); + brushEngine.setBrushType(ui.currentBrushType); + } + if (ui.brushParams) { + setBrushParams(ui.brushParams); + brushEngine.setBrushParams(ui.brushParams); + } + roomUiRef.current[currentRoomId] = { + color: ui.color ?? color, + lineWidth: ui.lineWidth ?? lineWidth, + drawMode: ui.drawMode ?? drawMode, + shapeType: ui.shapeType ?? shapeType, + previousColor: ui.previousColor ?? previousColor, + selectedStamp: ui.selectedStamp ?? selectedStamp, + stampSettings: ui.stampSettings ?? stampSettings, + currentBrushType: ui.currentBrushType ?? currentBrushType, + brushParams: ui.brushParams ?? brushParams, + }; + const stacks = roomStacksRef.current[currentRoomId] || { + undo: [], + redo: [], + }; + setUndoStack(stacks.undo); + setRedoStack(stacks.redo); + const clip = roomClipboardRef.current[currentRoomId] || null; + if (setCutImageData) setCutImageData(clip); + }, [currentRoomId]); + + // Load template objects when templateId changes + useEffect(() => { + + if (!templateId) { + setTemplateObjects([]); + return; + } + + const template = TEMPLATE_LIBRARY.find(t => t.id === templateId); + + if (template && template.canvas && template.canvas.objects) { + setTemplateObjects(template.canvas.objects); + } else { + setTemplateObjects([]); + } + }, [templateId, currentRoomId]); + + // Force redraw whenever templateObjects change (ensures templates appear immediately) + useEffect(() => { + if (!templateObjects || templateObjects.length === 0) return; + + const timer = setTimeout(() => { + if (drawAllDrawingsRef.current) { + lastDrawnStateRef.current = null; // Force redraw by clearing cache + drawAllDrawingsRef.current(); + } else { + console.warn('drawAllDrawingsRef not ready yet'); + } + }, 100); + + return () => clearTimeout(timer); + }, [templateObjects]); + + useEffect(() => { + if (!currentRoomId) return; + const ui = { color, lineWidth, drawMode, shapeType, previousColor, selectedStamp, stampSettings, currentBrushType, brushParams }; + roomUiRef.current[currentRoomId] = ui; + try { + localStorage.setItem( + `rescanvas:toolbar:${currentRoomId}`, + JSON.stringify(ui) + ); + } catch { } + }, [currentRoomId, color, lineWidth, drawMode, shapeType, previousColor, selectedStamp, stampSettings, currentBrushType, brushParams]); + + useEffect(() => { + if (!currentRoomId) return; + const cur = roomStacksRef.current[currentRoomId] || { undo: [], redo: [] }; + cur.undo = undoStack; + roomStacksRef.current[currentRoomId] = cur; + }, [currentRoomId, undoStack]); + useEffect(() => { + if (!currentRoomId) return; + const cur = roomStacksRef.current[currentRoomId] || { undo: [], redo: [] }; + cur.redo = redoStack; + roomStacksRef.current[currentRoomId] = cur; + }, [currentRoomId, redoStack]); + + useEffect(() => { + const handleMouseUp = () => { + setIsPanning(false); + panOriginRef.current = { ...panOffset }; + try { + if (panEndRefreshTimerRef.current) { + clearTimeout(panEndRefreshTimerRef.current); + panEndRefreshTimerRef.current = null; + } + if (panRefreshSkippedRef.current) { + panRefreshSkippedRef.current = false; + mergedRefreshCanvas("pan-mouseup-skipped").finally(() => { + try { + setIsLoading(false); + } catch (e) { } + }); + } + if (pendingPanRefreshRef.current) { + pendingPanRefreshRef.current = false; + mergedRefreshCanvas("pan-mouseup-pending").finally(() => { + try { + setIsLoading(false); + } catch (e) { } + }); + } + } catch (e) { } + }; + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [panOffset]); + + // Process submission queue to ensure strokes are submitted sequentially + const processSubmissionQueue = async () => { + if (isSubmittingRef.current || submissionQueueRef.current.length === 0) { + return; + } + + isSubmittingRef.current = true; + + while (submissionQueueRef.current.length > 0) { + const submission = submissionQueueRef.current.shift(); + try { + await submission(); + } catch (error) { + console.error("Error processing queued submission:", error); + } + } + + isSubmittingRef.current = false; + + // After processing all queued submissions, schedule a refresh to sync with backend + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = setTimeout(() => { + mergedRefreshCanvas("post-queue").catch((e) => + console.error("Error during post-queue refresh:", e) + ); + refreshTimerRef.current = null; + }, 500); + }; + + + const submitAIDrawings = async (aiPayload) => { try { - const uname = - getUsername(auth) || - `anon_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; - currentUserRef.current = `${uname}|${Date.now()}`; - } catch (e) { - currentUserRef.current = `anon_${Date.now()}_${Math.random() + const drawings = aiPayloadToDrawings({ + aiPayload, + currentUser, + generateId: (prefix) => + `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + }); + + const batchId = `ai_batch_${Date.now()}_${Math.random() .toString(36) - .substr(2, 5)}`; - } - } - const currentUser = currentUserRef.current; - - const [drawing, setDrawing] = useState(false); - const [color, setColor] = useState("#000000"); - const [lineWidth, setLineWidth] = useState(5); - const [drawMode, setDrawMode] = useState("freehand"); - const [shapeType, setShapeType] = useState("circle"); - const [brushStyle] = useState("round"); - const [shapeStart, setShapeStart] = useState(null); - - const brushEngine = useBrushEngine(); - const [currentBrushType, setCurrentBrushType] = useState("normal"); - const [brushParams, setBrushParams] = useState({}); - const [selectedStamp, setSelectedStamp] = useState(null); - const [stampSettings, setStampSettings] = useState({ - size: 50, - rotation: 0, - opacity: 100, - }); - const [backendStamps, setBackendStamps] = useState([]); - const [stampPreview, setStampPreview] = useState(null); // { x, y, stamp, settings } - const stampPreviewRef = useRef(null); - const [activeFilter, setActiveFilter] = useState(null); - const [filterParams, setFilterParams] = useState({}); - const [isFilterPreview, setIsFilterPreview] = useState(false); - const filterCanvasRef = useRef(null); - const originalCanvasDataRef = useRef(null); // For preview mode undo - const preFilterCanvasStateRef = useRef(null); - - const [showColorPicker, setShowColorPicker] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - const [previousColor, setPreviousColor] = useState(null); - const [clearDialogOpen, setClearDialogOpen] = useState(false); - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); - const [undoAvailable, setUndoAvailable] = useState(false); - const [redoAvailable, setRedoAvailable] = useState(false); - const [hasFilters, setHasFilters] = useState(false); // Track if filters exist for UI updates - - const [templateObjects, setTemplateObjects] = useState([]); - const templateObjectsRef = useRef([]); - - useEffect(() => { - templateObjectsRef.current = templateObjects; - }, [templateObjects]); - - const canvasWidth = DEFAULT_CANVAS_WIDTH; - const canvasHeight = DEFAULT_CANVAS_HEIGHT; - - const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); - const [isPanning, setIsPanning] = useState(false); - const panStartRef = useRef({ x: 0, y: 0 }); - const panOriginRef = useRef({ x: 0, y: 0 }); - const PAN_REFRESH_COOLDOWN_MS = 2000; - const panLastRefreshRef = useRef(0); - const panRefreshSkippedRef = useRef(false); - const panEndRefreshTimerRef = useRef(null); - const pendingPanRefreshRef = useRef(false); - const [pendingDrawings, setPendingDrawings] = useState([]); - const refreshTimerRef = useRef(null); - const submissionQueueRef = useRef([]); - const isSubmittingRef = useRef(false); - const confirmedStrokesRef = useRef(new Set()); - const lastDrawnStateRef = useRef(null); // Track last drawn state to avoid redundant redraws - const isDrawingInProgressRef = useRef(false); // Prevent concurrent drawing operations - const offscreenCanvasRef = useRef(null); // Offscreen canvas for flicker free rendering - const forceNextRedrawRef = useRef(false); // Force next redraw even if signature matches for undo redo - const [historyMode, setHistoryMode] = useState(false); - const [historyRange, setHistoryRange] = useState(null); // {start, end} in epoch ms - const [historyDialogOpen, setHistoryDialogOpen] = useState(false); - const [historyStartInput, setHistoryStartInput] = useState(""); - const [historyEndInput, setHistoryEndInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const [localSnack, setLocalSnack] = useState({ - open: false, - message: "", - duration: 4000, - }); - const [confirmDestructiveOpen, setConfirmDestructiveOpen] = useState(false); - const [destructiveConfirmText, setDestructiveConfirmText] = useState(""); - const showLocalSnack = (msg, duration = 4000) => - setLocalSnack({ open: true, message: String(msg), duration }); - - // editingEnabled controls whether the user can perform mutating actions. - // When historyMode is active, a specific user is selected for replay, or - // when viewOnly is true (room is archived or user is a viewer), editing - // should be disabled. - // For secure rooms, wallet must be connected to allow editing. - const editingEnabled = !( - historyMode || - (selectedUser && selectedUser !== "") || - viewOnly || - (roomType === "secure" && !walletConnected) - ); - const closeLocalSnack = () => - setLocalSnack({ open: false, message: "", duration: 4000 }); - - // Keyboard shortcuts state - const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false); - const shortcutManagerRef = useRef(null); - - const roomUiRef = useRef({}); - const previousSelectedUserRef = useRef(null); // Track previous selectedUser to detect changes - const isRefreshingSelectedUserRef = useRef(false); // Prevent concurrent refreshes - const selectedUserRefreshQueueRef = useRef(null); // Queue the next refresh target - const selectedUserAbortControllerRef = useRef(null); // Cancel pending operations - const roomStacksRef = useRef({}); - const roomClipboardRef = useRef({}); - const roomClearedAtRef = useRef({}); - const drawAllDrawingsRef = useRef(null); // Store reference to drawAllDrawings function - - useEffect(() => { - if (!currentRoomId) return; - const ui = - roomUiRef.current[currentRoomId] || - JSON.parse( - localStorage.getItem(`rescanvas:toolbar:${currentRoomId}`) || "null" - ) || - {}; - if (ui.color) setColor(ui.color); - if (ui.lineWidth) setLineWidth(ui.lineWidth); - if (ui.drawMode) setDrawMode(ui.drawMode); - if (ui.shapeType) setShapeType(ui.shapeType); - if (ui.previousColor !== undefined) setPreviousColor(ui.previousColor); - if (ui.selectedStamp) setSelectedStamp(ui.selectedStamp); - if (ui.stampSettings) setStampSettings(ui.stampSettings); - if (ui.currentBrushType) { - setCurrentBrushType(ui.currentBrushType); - brushEngine.setBrushType(ui.currentBrushType); - } - if (ui.brushParams) { - setBrushParams(ui.brushParams); - brushEngine.setBrushParams(ui.brushParams); - } - roomUiRef.current[currentRoomId] = { - color: ui.color ?? color, - lineWidth: ui.lineWidth ?? lineWidth, - drawMode: ui.drawMode ?? drawMode, - shapeType: ui.shapeType ?? shapeType, - previousColor: ui.previousColor ?? previousColor, - selectedStamp: ui.selectedStamp ?? selectedStamp, - stampSettings: ui.stampSettings ?? stampSettings, - currentBrushType: ui.currentBrushType ?? currentBrushType, - brushParams: ui.brushParams ?? brushParams, - }; - const stacks = roomStacksRef.current[currentRoomId] || { - undo: [], - redo: [], - }; - setUndoStack(stacks.undo); - setRedoStack(stacks.redo); - const clip = roomClipboardRef.current[currentRoomId] || null; - if (setCutImageData) setCutImageData(clip); - }, [currentRoomId]); - - // Load template objects when templateId changes - useEffect(() => { - - if (!templateId) { - setTemplateObjects([]); - return; - } - - const template = TEMPLATE_LIBRARY.find(t => t.id === templateId); - - if (template && template.canvas && template.canvas.objects) { - setTemplateObjects(template.canvas.objects); - } else { - setTemplateObjects([]); - } - }, [templateId, currentRoomId]); - - // Force redraw whenever templateObjects change (ensures templates appear immediately) - useEffect(() => { - if (!templateObjects || templateObjects.length === 0) return; - - const timer = setTimeout(() => { - if (drawAllDrawingsRef.current) { - lastDrawnStateRef.current = null; // Force redraw by clearing cache - drawAllDrawingsRef.current(); - } else { - console.warn('drawAllDrawingsRef not ready yet'); - } - }, 100); - - return () => clearTimeout(timer); - }, [templateObjects]); - - useEffect(() => { - if (!currentRoomId) return; - const ui = { color, lineWidth, drawMode, shapeType, previousColor, selectedStamp, stampSettings, currentBrushType, brushParams }; - roomUiRef.current[currentRoomId] = ui; - try { - localStorage.setItem( - `rescanvas:toolbar:${currentRoomId}`, - JSON.stringify(ui) - ); - } catch { } - }, [currentRoomId, color, lineWidth, drawMode, shapeType, previousColor, selectedStamp, stampSettings, currentBrushType, brushParams]); - - useEffect(() => { - if (!currentRoomId) return; - const cur = roomStacksRef.current[currentRoomId] || { undo: [], redo: [] }; - cur.undo = undoStack; - roomStacksRef.current[currentRoomId] = cur; - }, [currentRoomId, undoStack]); - useEffect(() => { - if (!currentRoomId) return; - const cur = roomStacksRef.current[currentRoomId] || { undo: [], redo: [] }; - cur.redo = redoStack; - roomStacksRef.current[currentRoomId] = cur; - }, [currentRoomId, redoStack]); - - useEffect(() => { - const handleMouseUp = () => { - setIsPanning(false); - panOriginRef.current = { ...panOffset }; - try { - if (panEndRefreshTimerRef.current) { - clearTimeout(panEndRefreshTimerRef.current); - panEndRefreshTimerRef.current = null; - } - if (panRefreshSkippedRef.current) { - panRefreshSkippedRef.current = false; - mergedRefreshCanvas("pan-mouseup-skipped").finally(() => { - try { - setIsLoading(false); - } catch (e) { } - }); - } - if (pendingPanRefreshRef.current) { - pendingPanRefreshRef.current = false; - mergedRefreshCanvas("pan-mouseup-pending").finally(() => { - try { - setIsLoading(false); - } catch (e) { } - }); - } - } catch (e) { } - }; - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [panOffset]); - - // Process submission queue to ensure strokes are submitted sequentially - const processSubmissionQueue = async () => { - if (isSubmittingRef.current || submissionQueueRef.current.length === 0) { - return; - } + .slice(2, 8)}`; - isSubmittingRef.current = true; - - while (submissionQueueRef.current.length > 0) { - const submission = submissionQueueRef.current.shift(); - try { - await submission(); - } catch (error) { - console.error("Error processing queued submission:", error); - } - } - - isSubmittingRef.current = false; - - // After processing all queued submissions, schedule a refresh to sync with backend - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - refreshTimerRef.current = setTimeout(() => { - mergedRefreshCanvas("post-queue").catch((e) => - console.error("Error during post-queue refresh:", e) - ); - refreshTimerRef.current = null; - }, 500); - }; - - useEffect(() => { - if (!auth?.token || !currentRoomId) return; - try { - setSocketToken(auth.token); - } catch (e) { } - - const socket = getSocket(auth.token); - - try { - socket.emit("join_room", { roomId: currentRoomId, token: auth?.token }); - } catch (e) { - socket.emit("join_room", { roomId: currentRoomId }); - } - - const scheduleRefresh = (delay = 300) => { - try { - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - } catch (e) { } - refreshTimerRef.current = setTimeout(() => { - mergedRefreshCanvas().catch((e) => - console.error("Error during scheduled refresh:", e) - ); - refreshTimerRef.current = null; - }, delay); - }; - - const handleNewStroke = (data) => { - try { - const myName = getUsername(auth); - if (data.user === myName) { - // This is confirmation of our own stroke - const stroke = data.stroke; - if (stroke && stroke.drawingId) { - confirmedStrokesRef.current.add(stroke.drawingId); - } - return; - } - } catch (e) { - try { - const user = getAuthUser(auth) || {}; - if (data.user === user.username) { - // This is confirmation of our own stroke - const stroke = data.stroke; - if (stroke && stroke.drawingId) { - confirmedStrokesRef.current.add(stroke.drawingId); - } - return; - } - } catch (e2) { } - } - - const stroke = data.stroke; - - // Extract metadata for advanced features (stamps, brushes, filters) - const metadata = { - brushStyle: stroke.brushStyle, - brushType: stroke.brushType, - brushParams: stroke.brushParams, - drawingType: stroke.drawingType, - stampData: stroke.stampData, - stampSettings: stroke.stampSettings, - filterType: stroke.filterType, - filterParams: stroke.filterParams, - }; - - const drawing = new Drawing( - stroke.drawingId || - `remote_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, - stroke.color || "#000000", - stroke.lineWidth || 5, - stroke.pathData || [], - stroke.ts || stroke.timestamp || Date.now(), - stroke.user || "Unknown", - metadata - ); - - try { - const clearedAt = roomClearedAtRef.current[currentRoomId]; - if ( - clearedAt && - (drawing.timestamp || drawing.ts || Date.now()) < clearedAt - ) { - return; - } - } catch (e) { } - - setPendingDrawings((prev) => [...prev, drawing]); - - // If this is a custom stamp, add it to the stamp panel - if (drawing.drawingType === "stamp" && drawing.stampData && drawing.stampData.image) { - setBackendStamps((prevStamps) => { - const imageKey = drawing.stampData.image.substring(0, 100); - const alreadyExists = prevStamps.some(s => - s.image && s.image.substring(0, 100) === imageKey - ); - - if (!alreadyExists) { - console.log('Adding new custom stamp from Socket.IO:', drawing.stampData.name || 'Custom Stamp'); - return [...prevStamps, { - id: `stamp-${Date.now()}-${prevStamps.length}`, - name: drawing.stampData.name || 'Custom Stamp', - category: drawing.stampData.category || 'custom', - image: drawing.stampData.image, - emoji: drawing.stampData.emoji - }]; - } - return prevStamps; - }); - } - - // Use requestAnimationFrame for smoother rendering - requestAnimationFrame(() => { - drawAllDrawings(); + const submitted = []; + console.info("[AI] submitting generated drawings", { + batchId, + count: drawings.length, + roomId: currentRoomId, }); - scheduleRefresh(350); - }; - - const handleUserJoined = (data) => { - try { - if (!data) return; - if (data.roomId !== currentRoomId) return; - console.debug("socket user_joined event", data); - if (data.username) { - showLocalSnack(`${data.username} joined the canvas.`); - } - } catch (e) { } - }; - - const handleUserLeft = (data) => { - try { - if (!data) return; - if (data.roomId !== currentRoomId) return; - console.debug("socket user_left event", data); - if (data.username) { - showLocalSnack(`${data.username} left the canvas.`); - } - } catch (e) { } - }; - - const handleStrokeUndone = (data) => { - console.log("Stroke undone event received:", data); - - forceNextRedrawRef.current = true; - lastDrawnStateRef.current = null; - - // Schedule refresh instead of immediate refresh to avoid flicker - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - refreshTimerRef.current = setTimeout(() => { - mergedRefreshCanvas("undo-event"); - refreshTimerRef.current = null; - }, 100); - - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } - }; - - const handleCanvasCleared = (data) => { - console.log("Canvas cleared event received:", data); - const clearedAt = data && data.clearedAt ? data.clearedAt : Date.now(); - if (currentRoomId) roomClearedAtRef.current[currentRoomId] = clearedAt; - - // Clear local authoritative drawings and pending drawings that predate the clear - try { - userData.clearDrawings(); - } catch (e) { } - setPendingDrawings([]); - serverCountRef.current = 0; - - setUndoStack([]); - setRedoStack([]); - setUndoAvailable(false); - setRedoAvailable(false); - try { - if (currentRoomId) { - roomStacksRef.current[currentRoomId] = { undo: [], redo: [] }; - roomClipboardRef.current[currentRoomId] = null; - } - } catch (e) { } - - clearCanvasForRefresh(); - drawAllDrawings(); - - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } - }; - - socket.on("new_stroke", handleNewStroke); - socket.on("stroke_undone", handleStrokeUndone); - socket.on("canvas_cleared", handleCanvasCleared); - socket.on("user_joined", handleUserJoined); - socket.on("user_left", handleUserLeft); - socket.on("user_joined_debug", (d) => { - console.debug("socket user_joined_debug", d); - }); - - return () => { - socket.off("new_stroke", handleNewStroke); - socket.off("stroke_undone", handleStrokeUndone); - socket.off("canvas_cleared", handleCanvasCleared); - socket.off("user_joined", handleUserJoined); - socket.off("user_left", handleUserLeft); - try { - socket.emit("leave_room", { - roomId: currentRoomId, - token: auth?.token, - }); - } catch (e) { - socket.emit("leave_room", { roomId: currentRoomId }); - } - try { - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - } catch (e) { } - }; - }, [auth?.token, currentRoomId, auth?.user?.username]); - - useEffect(() => { - (async () => { - try { - setUndoStack([]); - setRedoStack([]); - setUndoAvailable(false); - setRedoAvailable(false); - if (currentRoomId) { - roomStacksRef.current[currentRoomId] = { undo: [], redo: [] }; - } - - // Reset selectedUser tracking when room changes - previousSelectedUserRef.current = null; - isRefreshingSelectedUserRef.current = false; - selectedUserRefreshQueueRef.current = null; - - if (auth?.token && currentRoomId) { - try { - await resetMyStacks(auth.token, currentRoomId); - } catch (e) { } - } - - if (currentRoomId) { - try { - await checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } catch (e) { } - } - } catch (e) { } - })(); - }, [auth?.token, currentRoomId]); - - useEffect(() => { - try { - setUndoStack([]); - setRedoStack([]); - if (currentRoomId) { - roomStacksRef.current[currentRoomId] = { undo: [], redo: [] }; - } - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ).catch(() => { }); - } - } catch (e) { } - }, [auth?.token, currentRoomId]); - - // Force full refresh when selectedUser changes (drawing history selection/deselection) - useEffect(() => { - if (!currentRoomId || !auth?.token) return; - - // Serialize selectedUser for comparison (handles both string and object) - const serializeSelectedUser = (user) => { - if (!user || user === "") return ""; - if (typeof user === "string") return user; - if (typeof user === "object") - return JSON.stringify({ - user: user.user, - periodStart: user.periodStart, - }); - return String(user); - }; - - const currentSerialized = serializeSelectedUser(selectedUser); - const previousSerialized = previousSelectedUserRef.current; - - // Only refresh if selectedUser actually changed - if (currentSerialized === previousSerialized) { - return; - } - - // If a refresh is in progress, queue this change for execution after current one completes - if (isRefreshingSelectedUserRef.current) { - console.debug( - "[selectedUser] Refresh in progress, queuing new selection:", - currentSerialized - ); - selectedUserRefreshQueueRef.current = currentSerialized; - return; - } - - const performRefresh = async (targetSerialized) => { - isRefreshingSelectedUserRef.current = true; - - try { - setIsLoading(true); - - // Update the ref to mark this as the last processed value - previousSelectedUserRef.current = targetSerialized; - - // Force complete refresh from backend - userData.drawings = []; - setPendingDrawings([]); - serverCountRef.current = 0; - lastDrawnStateRef.current = null; - - const isDeselect = !selectedUser || selectedUser === ""; - const logLabel = isDeselect - ? "selectedUser-deselect" - : "selectedUser-select"; - console.debug(`[selectedUser] Performing full refresh: ${logLabel}`, { - to: targetSerialized, - }); - - await clearCanvasForRefresh(); - await mergedRefreshCanvas(logLabel); - await drawAllDrawings(); - } catch (error) { - console.error("Error refreshing on selectedUser change:", error); - } finally { - setIsLoading(false); - isRefreshingSelectedUserRef.current = false; - - // Check if there's a queued refresh waiting - if (selectedUserRefreshQueueRef.current !== null) { - const queuedTarget = selectedUserRefreshQueueRef.current; - selectedUserRefreshQueueRef.current = null; - - // Only process queued refresh if it's different from what we just processed - if (queuedTarget !== targetSerialized) { - console.debug( - "[selectedUser] Processing queued selection:", - queuedTarget - ); - // Use setTimeout to break out of the current call stack - setTimeout(() => performRefresh(queuedTarget), 0); - } - } - } - }; - - // Start the refresh - performRefresh(currentSerialized); - }, [selectedUser, currentRoomId]); - - const clearCanvasForRefresh = async () => { - const canvas = canvasRef.current; - if (!canvas) return; // Guard against null ref during tests - - const context = canvas.getContext("2d"); - if (!context) return; // Guard against null context during tests - - context.clearRect(0, 0, canvasWidth, canvasHeight); - setUserData(initializeUserData()); - setPendingDrawings([]); - serverCountRef.current = 0; - - // Clear selection overlay artifacts - setSelectionRect(null); - setSelectionStart(null); - - // Reset draw mode to freehand if in select mode - if (drawMode === "select") { - setDrawMode("freehand"); - } - }; - - const refreshCanvasButtonHandler = async () => { - if (isRefreshing) return; - setIsRefreshing(true); - setIsLoading(true); - try { - // Force full refresh from backend by clearing local state - userData.drawings = []; - setPendingDrawings([]); - serverCountRef.current = 0; - lastDrawnStateRef.current = null; - - await clearCanvasForRefresh(); - await mergedRefreshCanvas("refresh-button"); - await drawAllDrawings(); - updateFilterState(); // Update filter state after refresh - } catch (error) { - console.error("Error during canvas refresh:", error); - handleAuthError(error); - } finally { - setIsRefreshing(false); - setIsLoading(false); - } - }; - - const initializeUserData = () => { - const uniqueUserId = - auth?.user?.id || - `user_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; - const username = auth?.user?.username || "MainUser"; - return new UserData(uniqueUserId, username); - }; - const [userData, setUserData] = useState(() => initializeUserData()); - const generateId = () => - `drawing_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; - const serverCountRef = useRef(0); - - // Helper function to update filter state - const updateFilterState = () => { - // Use setUserData callback to read current state accurately - setUserData((currentUserData) => { - const filterExists = currentUserData.drawings.some((d) => d.drawingType === "filter"); - const filterCount = currentUserData.drawings.filter((d) => d.drawingType === "filter").length; - console.log(`[updateFilterState] filterExists=${filterExists}, filterCount=${filterCount}`); - setHasFilters(filterExists); - return currentUserData; - }); - }; - - // Advanced Brush/Stamp/Filter Functions - const handleBrushSelect = (brushType) => { - console.log("handleBrushSelect called with:", brushType); - setCurrentBrushType(brushType); - brushEngine.setBrushType(brushType); - setDrawMode("freehand"); - console.log("Current brush type set to:", brushType); - }; - - const handleBrushParamsChange = (params) => { - setBrushParams(params); - brushEngine.setBrushParams(params); - }; - - const placeStamp = async (x, y, stamp, settings) => { - const canvas = canvasRef.current; - if (!canvas || !stamp) return; - - const context = canvas.getContext("2d"); - - // Render stamp immediately using proper context management - if (stamp.emoji) { - context.save(); - context.globalAlpha = settings.opacity / 100; - context.translate(x, y); - context.rotate((settings.rotation * Math.PI) / 180); - - const size = settings.size; - context.font = `${size}px serif`; - context.textAlign = "center"; - context.textBaseline = "middle"; - context.fillText(stamp.emoji, 0, 0); - - context.restore(); - } else if (stamp.image) { - // For image stamps, load and render synchronously using async/await - try { - const img = await new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error("Failed to load image")); - image.src = stamp.image; - }); - - context.save(); - context.globalAlpha = settings.opacity / 100; - context.translate(x, y); - context.rotate((settings.rotation * Math.PI) / 180); - - const size = settings.size; - context.drawImage(img, -size / 2, -size / 2, size, size); - - context.restore(); - } catch (error) { - console.error("Failed to load stamp image:", stamp.image?.substring(0, 100), error); - } - } - - // Create drawing record for stamp - const stampDrawing = new Drawing( - generateId(), - color, - lineWidth, - [{ x, y }], - Date.now(), - currentUser, - { - drawingType: "stamp", - stampData: stamp, - stampSettings: settings, - } - ); - - stampDrawing.roomId = currentRoomId; - - userData.addDrawing(stampDrawing); - setPendingDrawings((prev) => [...prev, stampDrawing]); - - // Add to undo stack - setUndoStack((prev) => [...prev, stampDrawing]); - setRedoStack([]); - - // Use submission queue to ensure stamps are submitted in order - try { - const submitTask = async () => { - try { - console.log("Submitting queued stamp:", { - drawingId: stampDrawing.drawingId, - stampData: stampDrawing.stampData, - }); - - await submitToDatabase( - stampDrawing, - auth, - { roomId: currentRoomId, roomType }, - setUndoAvailable, - setRedoAvailable - ); - - console.log("Stamp submitted successfully:", stampDrawing.drawingId); - - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } - } catch (error) { - console.error("Error during queued stamp submission:", error); - setPendingDrawings((prev) => - prev.filter((d) => d.drawingId !== stampDrawing.drawingId) - ); - handleAuthError(error); - showLocalSnack("Failed to save stamp. Please try again."); - } - }; - - submissionQueueRef.current.push(submitTask); - processSubmissionQueue(); - } catch (error) { - console.error("Error preparing stamp submission:", error); - handleAuthError(error); - showLocalSnack("Failed to prepare stamp. Please try again."); - } - }; - - const handleStampSelect = (stamp, settings) => { - setSelectedStamp(stamp); - setStampSettings(settings); - setDrawMode("stamp"); - }; - - const handleStampChange = (stamp, settings) => { - setSelectedStamp(stamp); - setStampSettings(settings); - }; - - const applyFilter = async (filterType, params) => { - if (!canvasRef.current) return; - - // Always cancel preview mode first and clean up state - if (isFilterPreview) { - setIsFilterPreview(false); - } - preFilterCanvasStateRef.current = null; - originalCanvasDataRef.current = null; - - // Check if we already have a filter of this type applied - const existingFilterIndex = userData.drawings.findIndex( - (d) => d.drawingType === "filter" && d.filterType === filterType - ); - - let filterDrawing; - let isReplacement = existingFilterIndex !== -1; - - if (isReplacement) { - const existingFilter = userData.drawings[existingFilterIndex]; - existingFilter.filterParams = { ...params }; // Clone params - existingFilter.timestamp = Date.now(); - filterDrawing = existingFilter; - - // Update React state to reflect the filter parameter change - const newUserData = new UserData(userData.userId, userData.username); - newUserData.drawings = [...userData.drawings]; // Clone the array to trigger state update - setUserData(newUserData); - - // Force a complete redraw with the updated filter parameters - // This will redraw all strokes first, then apply the filter - lastDrawnStateRef.current = null; - forceNextRedrawRef.current = true; - await drawAllDrawings(); - - showLocalSnack(`Updated ${filterType} filter`); - updateFilterState(); - - // For filter updates, we need to submit the UPDATE to backend - // The backend should handle this as an update, not a new drawing - try { - await submitToDatabase( - filterDrawing, - auth, - { - roomId: currentRoomId, - roomType, - }, - setUndoAvailable, - setRedoAvailable - ); - - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } - } catch (error) { - console.error("Error submitting filter update:", error); - handleAuthError(error); - } - - return; // Exit early for updates - } - - // Create NEW filter record for new filter type - filterDrawing = new Drawing( - generateId(), - "#000000", - 0, - [], - Date.now(), - currentUser, - { - drawingType: "filter", - filterType, - filterParams: { ...params }, // Clone params - } - ); - - // Set filter properties directly on the drawing object - filterDrawing.drawingType = "filter"; - filterDrawing.filterType = filterType; - filterDrawing.filterParams = { ...params }; - filterDrawing.roomId = currentRoomId; - - userData.addDrawing(filterDrawing); - - // Update React state so components know about the new filter - const newUserData = new UserData(userData.userId, userData.username); - newUserData.drawings = [...userData.drawings]; // Clone array with new filter - setUserData(newUserData); - - setPendingDrawings((prev) => [...prev, filterDrawing]); - - setUndoStack((prev) => [...prev, filterDrawing]); - setRedoStack([]); - - // Force complete redraw this will render all strokes THEN apply filter - lastDrawnStateRef.current = null; - forceNextRedrawRef.current = true; - await drawAllDrawings(); - - showLocalSnack(`Applied ${filterType} filter`); - updateFilterState(); - - try { - await submitToDatabase( - filterDrawing, - auth, - { - roomId: currentRoomId, - roomType, - }, - setUndoAvailable, - setRedoAvailable - ); - - // Check undo/redo availability after filter submission + for (const drawing of drawings) { + drawing.roomId = currentRoomId; + drawing.parentPasteId = batchId; + + if (drawing.pathData && typeof drawing.pathData === "object") { + drawing.pathData.parentPasteId = batchId; + } + + userData.addDrawing(drawing); + setPendingDrawings((prev) => [...prev, drawing]); + + await submitToDatabase( + drawing, + auth, + { + roomId: currentRoomId, + roomType, + skipUndoStack: true, + }, + setUndoAvailable, + setRedoAvailable + ); + + submitted.push(drawing); + } + + const batchMarker = new Drawing( + batchId, + "#FFFFFF", + 1, + { + tool: "paste", + cut: false, + pastedDrawingIds: submitted.map((d) => d.drawingId), + aiGenerated: true, + }, + Date.now(), + currentUser + ); + + await submitToDatabase( + batchMarker, + auth, + { roomId: currentRoomId, roomType }, + setUndoAvailable, + setRedoAvailable + ); + + setUndoStack((prev) => [ + ...prev, + { + type: "paste", + pastedDrawings: submitted, + backendCount: 1, + aiGenerated: true, + }, + ]); + setRedoStack([]); + + requestAnimationFrame(() => { + drawAllDrawings(); + }); + if (currentRoomId) { - checkUndoRedoAvailability( + await checkUndoRedoAvailability( auth, setUndoAvailable, - setRedoAvailable, - currentRoomId + setRedoAvailable, + currentRoomId ); } - } catch (error) { - console.error("Error submitting filter:", error); - // On error, remove the failed filter from pending - setPendingDrawings((prev) => - prev.filter((d) => d.drawingId !== filterDrawing.drawingId) - ); - handleAuthError(error); - } - }; - - const previewFilter = async (filterType, params) => { - const canvas = canvasRef.current; - if (!canvas) return; - // If already in preview mode, first restore to base state - if (isFilterPreview && preFilterCanvasStateRef.current) { - const img = new Image(); - img.onload = async () => { - const context = canvas.getContext("2d"); - context.clearRect(0, 0, canvas.width, canvas.height); - context.drawImage(img, 0, 0); - - // Now apply the new preview - await applyPreviewFilter(canvas, filterType, params); - }; - img.src = preFilterCanvasStateRef.current; - return; - } - - // Store the current canvas state before preview (only once) - if (!preFilterCanvasStateRef.current) { - preFilterCanvasStateRef.current = canvas.toDataURL(); - } - - await applyPreviewFilter(canvas, filterType, params); - }; - - const applyPreviewFilter = async (canvas, filterType, params) => { - // Check if this filter type already exists in the drawings - const existingFilterIndex = userData.drawings.findIndex( - (d) => d.drawingType === "filter" && d.filterType === filterType - ); - - if (existingFilterIndex !== -1) { - // Temporarily remove this filter, redraw, then apply preview - const originalDrawings = [...userData.drawings]; - userData.drawings = userData.drawings.filter((d, i) => i !== existingFilterIndex); - - lastDrawnStateRef.current = null; - forceNextRedrawRef.current = true; - await drawAllDrawings(); - - // Restore drawings array - userData.drawings = originalDrawings; - } - - // Apply the preview filter on top of current canvas - const context = canvas.getContext("2d"); - const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - const filteredImageData = applyImageFilter(imageData, filterType, params); - context.putImageData(filteredImageData, 0, 0); - - setIsFilterPreview(true); - }; - - const undoFilter = async () => { - // If in preview mode, restore from saved canvas state - if (isFilterPreview && preFilterCanvasStateRef.current) { - const canvas = canvasRef.current; - if (!canvas) return; - - const context = canvas.getContext("2d"); - const img = new Image(); - img.onload = async () => { - context.clearRect(0, 0, canvas.width, canvas.height); - context.drawImage(img, 0, 0); - setIsFilterPreview(false); - preFilterCanvasStateRef.current = null; - originalCanvasDataRef.current = null; - }; - img.src = preFilterCanvasStateRef.current; - return; - } - - // If not in preview mode, use regular undo (which properly syncs with backend) - if (!editingEnabled) { - showLocalSnack("Undo is disabled in view-only mode."); - return; - } - - if (undoStack.length === 0) { - showLocalSnack("No actions to undo."); - return; - } - - // Simply call the regular undo function, which will undo the last action - // This properly coordinates with the backend's undo system - await undo(); - }; - - const clearAllFilters = async () => { - // Clear preview state if active - if (isFilterPreview) { - setIsFilterPreview(false); - preFilterCanvasStateRef.current = null; - originalCanvasDataRef.current = null; - } - - if (!editingEnabled) { - showLocalSnack("Clear filters is disabled in view-only mode."); - return; - } - - // Find all filter drawings in userData (not just undo stack) - // Use setUserData callback to get the latest state - let filterDrawings = []; - setUserData((currentUserData) => { - const allDrawings = currentUserData.drawings || []; - filterDrawings = allDrawings.filter( - (drawing) => drawing.drawingType === "filter" - ); - console.log(`[clearAllFilters] Found ${filterDrawings.length} filters to clear`, filterDrawings); - return currentUserData; - }); - - if (filterDrawings.length === 0) { - showLocalSnack("No filters to clear."); - return; - } - - if (isRefreshing) { - showLocalSnack("Please wait for the canvas to refresh."); - return; - } - - try { - showLocalSnack(`Clearing ${filterDrawings.length} filter(s)...`); - - // Get filter IDs before removing from local state - const filterIds = filterDrawings.map(f => f.drawingId).filter(id => id); - - // Remove all filter drawings from local state immediately using proper state update - setUserData((currentUserData) => { - const newUserData = new UserData(currentUserData.userId, currentUserData.username); - newUserData.drawings = currentUserData.drawings.filter( - (d) => d.drawingType !== "filter" - ); - console.log(`[clearAllFilters] Removed ${filterDrawings.length} filters, ${newUserData.drawings.length} drawings remain`); - return newUserData; + console.info("[AI] drawing batch stored", { + batchId, + submittedIds: submitted.map((drawing) => drawing.drawingId), }); - - // Remove from pendingDrawings - setPendingDrawings((prev) => - prev.filter((d) => d.drawingType !== "filter") - ); - - // Remove from undo stack (if present) - setUndoStack((prev) => - prev.filter((d) => d.drawingType !== "filter") - ); - - // Force a complete redraw immediately to show filters are gone - lastDrawnStateRef.current = null; - forceNextRedrawRef.current = true; - await drawAllDrawings(); - - showLocalSnack(`Cleared ${filterDrawings.length} filter(s).`); - updateFilterState(); // Update filter state for UI - - // Now sync with backend - create undo markers for each filter - if (filterIds.length > 0) { - try { - // Import the API function - const { markStrokesAsUndone } = await import('../api/rooms'); - - try { - await markStrokesAsUndone(auth.token, currentRoomId, filterIds); - console.log(`Marked ${filterIds.length} filters as undone in backend`); - } catch (apiError) { - // If the API doesn't exist, fall back to calling undo multiple times - console.warn("markStrokesAsUndone API not available, using fallback"); - - // Fallback: call regular undo endpoint for each filter - const { undoRoomAction } = await import('../api/rooms'); - for (let i = 0; i < Math.min(filterDrawings.length, 10); i++) { - try { - const result = await undoRoomAction(auth.token, currentRoomId); - if (result.status === "noop") break; - await new Promise(resolve => setTimeout(resolve, 50)); - } catch (e) { - console.warn("Error calling undoRoomAction:", e); - break; - } - } - } - - await checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } catch (e) { - console.error("Error syncing filter removal with backend:", e); - } - } } catch (error) { - console.error("Error clearing all filters:", error); - showLocalSnack("Failed to clear all filters. Refreshing canvas..."); - // Refresh to restore state - await refreshCanvasButtonHandler(); - } - }; - - const applyImageFilter = (imageData, filterType, params) => { - const data = imageData.data; - const filtered = new ImageData( - new Uint8ClampedArray(data), - imageData.width, - imageData.height - ); - - switch (filterType) { - case "blur": - return applyBlurFilter(filtered, params.intensity || 5); - case "hueShift": - return applyHueShiftFilter( - filtered, - params.hue || 0, - params.saturation || 0 - ); - case "chalk": - return applyChalkFilter( - filtered, - params.roughness || 50, - params.opacity || 80 - ); - case "fade": - return applyFadeFilter(filtered, params.amount || 30); - case "vintage": - return applyVintageFilter( - filtered, - params.sepia || 60, - params.vignette || 40 - ); - case "neon": - return applyNeonFilter( - filtered, - params.intensity || 15, - params.color || 180 - ); - default: - return filtered; - } - }; - - const applyBlurFilter = (imageData, intensity) => { - // Optimized separable box blur - O(n) instead of O(n²) - // This is much faster and won't crash even with higher intensity values - const data = imageData.data; - const width = imageData.width; - const height = imageData.height; - - const radius = Math.max(1, Math.floor(intensity)); - const temp = new Uint8ClampedArray(data); - const result = new Uint8ClampedArray(data); - - // Horizontal pass - for (let y = 0; y < height; y++) { - let r = 0, g = 0, b = 0, a = 0; - let count = 0; - - // Initialize window - for (let x = -radius; x <= radius; x++) { - if (x >= 0 && x < width) { - const idx = (y * width + x) * 4; - r += data[idx]; - g += data[idx + 1]; - b += data[idx + 2]; - a += data[idx + 3]; - count++; - } - } - - // Slide window across row - for (let x = 0; x < width; x++) { - const idx = (y * width + x) * 4; - temp[idx] = r / count; - temp[idx + 1] = g / count; - temp[idx + 2] = b / count; - temp[idx + 3] = a / count; - - // Remove left pixel - const leftX = x - radius; - if (leftX >= 0) { - const leftIdx = (y * width + leftX) * 4; - r -= data[leftIdx]; - g -= data[leftIdx + 1]; - b -= data[leftIdx + 2]; - a -= data[leftIdx + 3]; - count--; - } - - // Add right pixel - const rightX = x + radius + 1; - if (rightX < width) { - const rightIdx = (y * width + rightX) * 4; - r += data[rightIdx]; - g += data[rightIdx + 1]; - b += data[rightIdx + 2]; - a += data[rightIdx + 3]; - count++; - } - } - } - - // Vertical pass - for (let x = 0; x < width; x++) { - let r = 0, g = 0, b = 0, a = 0; - let count = 0; - - // Initialize window - for (let y = -radius; y <= radius; y++) { - if (y >= 0 && y < height) { - const idx = (y * width + x) * 4; - r += temp[idx]; - g += temp[idx + 1]; - b += temp[idx + 2]; - a += temp[idx + 3]; - count++; - } - } - - // Slide window down column - for (let y = 0; y < height; y++) { - const idx = (y * width + x) * 4; - result[idx] = r / count; - result[idx + 1] = g / count; - result[idx + 2] = b / count; - result[idx + 3] = a / count; - - // Remove top pixel - const topY = y - radius; - if (topY >= 0) { - const topIdx = (topY * width + x) * 4; - r -= temp[topIdx]; - g -= temp[topIdx + 1]; - b -= temp[topIdx + 2]; - a -= temp[topIdx + 3]; - count--; - } - - // Add bottom pixel - const bottomY = y + radius + 1; - if (bottomY < height) { - const bottomIdx = (bottomY * width + x) * 4; - r += temp[bottomIdx]; - g += temp[bottomIdx + 1]; - b += temp[bottomIdx + 2]; - a += temp[bottomIdx + 3]; - count++; - } - } - } - - return new ImageData(result, width, height); - }; - - const applyHueShiftFilter = (imageData, hueShift, saturationShift) => { - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - - // Convert RGB to HSL - const max = Math.max(r, g, b) / 255; - const min = Math.min(r, g, b) / 255; - const diff = max - min; - const sum = max + min; - - let h = 0; - const l = sum / 2; - const s = diff === 0 ? 0 : l > 0.5 ? diff / (2 - sum) : diff / sum; - - if (diff !== 0) { - switch (max) { - case r / 255: - h = (g - b) / 255 / diff + (g < b ? 6 : 0); - break; - case g / 255: - h = (b - r) / 255 / diff + 2; - break; - case b / 255: - h = (r - g) / 255 / diff + 4; - break; - } - h /= 6; - } - - // Apply shifts - h = (h + hueShift / 360) % 1; - const newS = Math.max(0, Math.min(1, s + saturationShift / 100)); - - // Convert back to RGB - const hue2rgb = (p, q, t) => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - - let newR, newG, newB; - - if (newS === 0) { - newR = newG = newB = l; - } else { - const q = l < 0.5 ? l * (1 + newS) : l + newS - l * newS; - const p = 2 * l - q; - newR = hue2rgb(p, q, h + 1 / 3); - newG = hue2rgb(p, q, h); - newB = hue2rgb(p, q, h - 1 / 3); - } - - data[i] = Math.round(newR * 255); - data[i + 1] = Math.round(newG * 255); - data[i + 2] = Math.round(newB * 255); - } - - return imageData; - }; - - const applyChalkFilter = (imageData, roughness, opacity) => { - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - const noise = (Math.random() - 0.5) * (roughness / 100) * 255; - const opacityFactor = opacity / 100; - - data[i] = Math.max(0, Math.min(255, data[i] + noise)) * opacityFactor; - data[i + 1] = - Math.max(0, Math.min(255, data[i + 1] + noise)) * opacityFactor; - data[i + 2] = - Math.max(0, Math.min(255, data[i + 2] + noise)) * opacityFactor; - data[i + 3] = data[i + 3] * opacityFactor; - } - - return imageData; - }; - - const applyFadeFilter = (imageData, amount) => { - const data = imageData.data; - const fadeAmount = 1 - amount / 100; - - for (let i = 0; i < data.length; i += 4) { - data[i + 3] = data[i + 3] * fadeAmount; - } - - return imageData; - }; - - const applyVintageFilter = (imageData, sepia, vignette) => { - const data = imageData.data; - const width = imageData.width; - const height = imageData.height; - const sepiaAmount = sepia / 100; - - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - - // Apply sepia - data[i] = Math.min( - 255, - (r * 0.393 + g * 0.769 + b * 0.189) * sepiaAmount + - r * (1 - sepiaAmount) - ); - data[i + 1] = Math.min( - 255, - (r * 0.349 + g * 0.686 + b * 0.168) * sepiaAmount + - g * (1 - sepiaAmount) - ); - data[i + 2] = Math.min( - 255, - (r * 0.272 + g * 0.534 + b * 0.131) * sepiaAmount + - b * (1 - sepiaAmount) - ); - - // Apply vignette - const x = (i / 4) % width; - const y = Math.floor(i / 4 / width); - const centerX = width / 2; - const centerY = height / 2; - const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); - const maxDistance = Math.sqrt(centerX ** 2 + centerY ** 2); - const vignetteAmount = 1 - (distance / maxDistance) * (vignette / 100); - - data[i] *= vignetteAmount; - data[i + 1] *= vignetteAmount; - data[i + 2] *= vignetteAmount; - } - - return imageData; - }; - - const applyNeonFilter = (imageData, intensity, hue) => { - const data = imageData.data; - const width = imageData.width; - const height = imageData.height; - const glowIntensity = intensity / 25; // More aggressive scaling (max 50 -> 2.0) - - // Create a copy for the glow effect - const result = new Uint8ClampedArray(data); - - // Generate neon color based on hue using proper HSL to RGB conversion - const hueNormalized = hue / 360; - const neonR = Math.abs(Math.sin((hueNormalized) * Math.PI * 2)) * 255; - const neonG = Math.abs(Math.sin((hueNormalized + 0.333) * Math.PI * 2)) * 255; - const neonB = Math.abs(Math.sin((hueNormalized + 0.666) * Math.PI * 2)) * 255; - - for (let i = 0; i < data.length; i += 4) { - const alpha = data[i + 3]; - - // Only apply effect to visible pixels (any stroke) - if (alpha > 5) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - - // Calculate brightness - const brightness = (r + g + b) / 3; - - // Apply aggressive neon glow with color tinting - const alphaFactor = alpha / 255; - const colorFactor = glowIntensity * alphaFactor; - - // Mix original color with neon color and boost brightness - const boost = 1 + (glowIntensity * 0.8); - result[i] = Math.min(255, (r * boost) + (neonR * colorFactor * 0.7)); - result[i + 1] = Math.min(255, (g * boost) + (neonG * colorFactor * 0.7)); - result[i + 2] = Math.min(255, (b * boost) + (neonB * colorFactor * 0.7)); - - // Ensure the effect is visible even on dark strokes - const minBrightness = 60 * glowIntensity; - const currentBrightness = (result[i] + result[i + 1] + result[i + 2]) / 3; - if (currentBrightness < minBrightness) { - const brightnessFactor = minBrightness / Math.max(currentBrightness, 1); - result[i] = Math.min(255, result[i] * brightnessFactor); - result[i + 1] = Math.min(255, result[i + 1] * brightnessFactor); - result[i + 2] = Math.min(255, result[i + 2] * brightnessFactor); - } - } - } - - return new ImageData(result, width, height); - }; - - const drawAllDrawings = async () => { - const currentTemplateObjects = templateObjectsRef.current || []; - - if (isDrawingInProgressRef.current) { - console.log('Drawing already in progress, skipping drawAllDrawings call'); - return; - } - - isDrawingInProgressRef.current = true; - - // Save current brush state - const savedBrushType = brushEngine ? brushEngine.brushType : null; - const savedBrushParams = brushEngine ? brushEngine.brushParams : null; - - try { - setIsLoading(true); - const canvas = canvasRef.current; - if (!canvas) { - setIsLoading(false); - isDrawingInProgressRef.current = false; - return; - } - const context = canvas.getContext("2d"); - if (!context) { - setIsLoading(false); - isDrawingInProgressRef.current = false; - return; - } - - // Include any locally-pending drawings (e.g. received via socket but - // not yet reflected by a backend refresh) so they render immediately. - const userDrawingIds = new Set((userData.drawings || []).map(d => d.drawingId)); - const uniquePendingDrawings = (pendingDrawings || []).filter( - pd => !userDrawingIds.has(pd.drawingId) - ); - - const combined = [ - ...(userData.drawings || []), - ...uniquePendingDrawings, - ]; - - // Create a state signature to detect if we need to redraw - // Include filter information to ensure redraw when filters change - const filterSignature = combined - .filter(d => d.drawingType === "filter") - .map(f => `${f.drawingId}:${f.filterType}`) - .join(','); - - const stateSignature = JSON.stringify({ - drawingCount: combined.length, - drawingIds: combined.map(d => d.drawingId).sort().join(','), - pendingCount: pendingDrawings.length, - templateCount: currentTemplateObjects?.length || 0, - templateIds: currentTemplateObjects?.map(t => `${t.type}:${t.x || t.x1 || t.cx}:${t.y || t.y1 || t.cy}`).join(',') || '', - filters: filterSignature - }); - - if (lastDrawnStateRef.current === stateSignature) { - console.log('State unchanged, skipping redraw'); - setIsLoading(false); - isDrawingInProgressRef.current = false; - return; - } - - // Clear force flag after checking it - forceNextRedrawRef.current = false; - lastDrawnStateRef.current = stateSignature; - - // for flicker free rendering - if (!offscreenCanvasRef.current || - offscreenCanvasRef.current.width !== canvasWidth || - offscreenCanvasRef.current.height !== canvasHeight - ) { - offscreenCanvasRef.current = document.createElement("canvas"); - offscreenCanvasRef.current.width = canvasWidth; - offscreenCanvasRef.current.height = canvasHeight; - } - - const offscreenContext = offscreenCanvasRef.current.getContext("2d"); - offscreenContext.imageSmoothingEnabled = false; - offscreenContext.clearRect(0, 0, canvasWidth, canvasHeight); - - // This avoids async rendering issues with image stamps - const stampsToRender = []; - - // Create and render template layer separately so it stays below all drawings - let templateCanvas = null; - if (currentTemplateObjects && currentTemplateObjects.length > 0) { - templateCanvas = document.createElement('canvas'); - templateCanvas.width = canvasWidth; - templateCanvas.height = canvasHeight; - const templateContext = templateCanvas.getContext('2d'); - templateContext.imageSmoothingEnabled = false; - - templateContext.save(); - templateContext.globalAlpha = 0.5; - - let renderedCount = 0; - for (const obj of currentTemplateObjects) { - try { - if (obj.type === 'line') { - templateContext.beginPath(); - templateContext.moveTo(obj.x1, obj.y1); - templateContext.lineTo(obj.x2, obj.y2); - templateContext.strokeStyle = obj.color || '#333'; - templateContext.lineWidth = obj.lineWidth || 2; - templateContext.stroke(); - renderedCount++; - } else if (obj.type === 'rectangle') { - templateContext.strokeStyle = obj.stroke || '#333'; - templateContext.lineWidth = obj.lineWidth || 2; - if (obj.fill && obj.fill !== 'transparent') { - templateContext.fillStyle = obj.fill; - templateContext.fillRect(obj.x, obj.y, obj.width, obj.height); - } - templateContext.strokeRect(obj.x, obj.y, obj.width, obj.height); - renderedCount++; - } else if (obj.type === 'circle') { - templateContext.beginPath(); - templateContext.arc(obj.cx, obj.cy, obj.radius, 0, Math.PI * 2); - templateContext.strokeStyle = obj.stroke || '#333'; - templateContext.lineWidth = obj.lineWidth || 2; - if (obj.fill && obj.fill !== 'transparent') { - templateContext.fillStyle = obj.fill; - templateContext.fill(); - } - templateContext.stroke(); - renderedCount++; - } else if (obj.type === 'ellipse') { - templateContext.beginPath(); - templateContext.ellipse(obj.cx, obj.cy, obj.rx, obj.ry, 0, 0, Math.PI * 2); - templateContext.strokeStyle = obj.stroke || '#333'; - templateContext.lineWidth = obj.lineWidth || 2; - if (obj.fill && obj.fill !== 'transparent') { - templateContext.fillStyle = obj.fill; - templateContext.fill(); - } - templateContext.stroke(); - renderedCount++; - } else if (obj.type === 'text') { - templateContext.fillStyle = obj.color || '#333'; - templateContext.font = `${obj.bold ? 'bold ' : ''}${obj.fontSize || 16}px Arial`; - templateContext.fillText(obj.text || '', obj.x, obj.y); - renderedCount++; - } else { - console.warn('Unknown template object type:', obj.type); - } - } catch (e) { - console.warn('Failed to render template object:', obj, e); - } - } - templateContext.restore(); - } else { - console.log('No template objects to render'); - } - - if (templateCanvas) { - offscreenContext.drawImage(templateCanvas, 0, 0); - } - - const cutOriginalIds = new Set(); - try { - combined.forEach((d) => { - if ( - d && - d.pathData && - d.pathData.tool === "cut" && - Array.isArray(d.pathData.originalStrokeIds) - ) { - d.pathData.originalStrokeIds.forEach((id) => - cutOriginalIds.add(id) - ); - } - }); - } catch (e) { } - - const sortedDrawings = combined.sort((a, b) => { - const orderA = - a.order !== undefined ? a.order : a.timestamp || a.ts || 0; - const orderB = - b.order !== undefined ? b.order : b.timestamp || b.ts || 0; - return orderA - orderB; - }); - - // Separate filter drawings from regular drawings - const regularDrawings = []; - const filterDrawings = []; - for (const drawing of sortedDrawings) { - if (drawing.drawingType === "filter") { - filterDrawings.push(drawing); - } else { - regularDrawings.push(drawing); - } - } - - // Pre-load all image stamps to ensure they render in correct z-order - const imageStampCache = new Map(); - const imageStampPromises = []; - - for (const drawing of regularDrawings) { - if (drawing.drawingType === "stamp" && drawing.stampData && drawing.stampData.image && !drawing.stampData.emoji) { - const imageUrl = drawing.stampData.image; - if (!imageStampCache.has(imageUrl)) { - const promise = new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - imageStampCache.set(imageUrl, img); - resolve(); - }; - img.onerror = () => { - console.error("[drawAllDrawings] Failed to pre-load stamp image:", imageUrl.substring(0, 100)); - resolve(); // Continue even if image fails - }; - img.src = imageUrl; - }); - imageStampPromises.push(promise); - } - } - } - - // Wait for all stamp images to load before rendering - if (imageStampPromises.length > 0) { - console.log("[drawAllDrawings] Pre-loading", imageStampPromises.length, "stamp images"); - await Promise.all(imageStampPromises); - console.log("[drawAllDrawings] All stamp images loaded"); - } - - // Render drawings in chronological order. When a 'cut' record appears - // we immediately apply a destination-out erase so it removes prior content - // but does not erase strokes that are drawn after the cut. - const maskedOriginals = new Set(); - let seenAnyCut = false; - - for (const drawing of regularDrawings) { - // If this is a cut record, apply the erase to the canvas now. - if (drawing && drawing.pathData && drawing.pathData.tool === "cut") { - seenAnyCut = true; - try { - if (Array.isArray(drawing.pathData.originalStrokeIds)) { - drawing.pathData.originalStrokeIds.forEach((id) => - maskedOriginals.add(id) - ); - } - } catch (e) { } - - if (drawing.pathData && drawing.pathData.rect) { - const r = drawing.pathData.rect; - offscreenContext.save(); - try { - offscreenContext.globalCompositeOperation = "destination-out"; - offscreenContext.fillStyle = "rgba(0,0,0,1)"; - // Expand rect slightly to avoid hairline due to subpixel antialiasing - offscreenContext.fillRect( - Math.floor(r.x) - 2, - Math.floor(r.y) - 2, - Math.ceil(r.width) + 4, - Math.ceil(r.height) + 4 - ); - } finally { - offscreenContext.restore(); - } - - // Restore template layer in the cut region so templates remain visible - if (templateCanvas) { - offscreenContext.drawImage( - templateCanvas, - Math.floor(r.x) - 2, - Math.floor(r.y) - 2, - Math.ceil(r.width) + 4, - Math.ceil(r.height) + 4, - Math.floor(r.x) - 2, - Math.floor(r.y) - 2, - Math.ceil(r.width) + 4, - Math.ceil(r.height) + 4 - ); - } - } - - continue; - } - - // Skip originals that have been masked by a cut - if ( - drawing && - drawing.drawingId && - (cutOriginalIds.has(drawing.drawingId) || - maskedOriginals.has(drawing.drawingId)) - ) { - continue; - } - - // Skip temporary white "erase" helper strokes when we've seen a cut - // record; destination-out masking is authoritative and drawing white - // strokes can produce hairlines. - try { - if ( - seenAnyCut && - drawing && - drawing.color && - typeof drawing.color === "string" && - drawing.color.toLowerCase() === "#ffffff" - ) { - continue; - } - } catch (e) { } - - // Draw the drawing normally - offscreenContext.globalAlpha = 1.0; - let viewingUser = null; - let viewingPeriodStart = null; - if (selectedUser) { - if (typeof selectedUser === "string") viewingUser = selectedUser; - else if (typeof selectedUser === "object") { - viewingUser = selectedUser.user; - viewingPeriodStart = selectedUser.periodStart; - } - } - if (viewingUser && drawing.user !== viewingUser) { - offscreenContext.globalAlpha = 0.1; - } else if (viewingPeriodStart !== null) { - const ts = drawing.timestamp || drawing.order || 0; - if ( - ts < viewingPeriodStart || - ts >= viewingPeriodStart + 5 * 60 * 1000 - ) { - offscreenContext.globalAlpha = 0.1; - } - } - - // Stamps have pathData as array but need special rendering - render inline to preserve z-order - if (drawing.drawingType === "stamp" && drawing.stampData && drawing.stampSettings && Array.isArray(drawing.pathData) && drawing.pathData.length > 0) { - const stamp = drawing.stampData; - const settings = drawing.stampSettings; - const position = drawing.pathData[0]; - - try { - offscreenContext.save(); - offscreenContext.translate(position.x, position.y); - offscreenContext.rotate(((settings.rotation || 0) * Math.PI) / 180); - - const size = settings.size || 50; - - if (stamp.emoji) { - // Render emoji stamp - offscreenContext.font = `${size}px serif`; - offscreenContext.textAlign = "center"; - offscreenContext.textBaseline = "middle"; - offscreenContext.fillText(stamp.emoji, 0, 0); - console.log("[drawAllDrawings] Rendered emoji stamp inline:", stamp.emoji); - } else if (stamp.image) { - // Render image stamp using pre-loaded image - const img = imageStampCache.get(stamp.image); - if (img) { - offscreenContext.globalAlpha = (settings.opacity || 100) / 100 * offscreenContext.globalAlpha; - offscreenContext.drawImage(img, -size / 2, -size / 2, size, size); - console.log("[drawAllDrawings] Rendered image stamp inline"); - } else { - console.warn("[drawAllDrawings] Image stamp not in cache:", stamp.image?.substring(0, 100)); - } - } - - offscreenContext.restore(); - } catch (error) { - offscreenContext.restore(); - console.error("[drawAllDrawings] Error rendering stamp:", error); - } - } else if (drawing.drawingType === "stamp") { - console.warn("[drawAllDrawings] Stamp NOT rendered - missing requirements:", { - drawingId: drawing.drawingId, - drawingType: drawing.drawingType, - hasStampData: !!drawing.stampData, - hasStampSettings: !!drawing.stampSettings, - pathDataIsArray: Array.isArray(drawing.pathData), - pathDataLength: drawing.pathData ? drawing.pathData.length : 0, - pathDataType: typeof drawing.pathData, - pathDataValue: drawing.pathData, - fullDrawing: drawing - }); - } else if (Array.isArray(drawing.pathData)) { - const pts = drawing.pathData; - if (pts.length > 0) { - // Check if this is an advanced brush drawing - if (drawing.brushType && drawing.brushType !== "normal" && brushEngine) { - console.log("Rendering advanced brush in drawAllDrawings:", { - id: drawing.drawingId, - brushType: drawing.brushType, - pointCount: pts.length - }); - - // Use brush engine to render advanced brush strokes - offscreenContext.save(); - brushEngine.updateContext(offscreenContext); - - // Start the stroke at the first point - offscreenContext.beginPath(); - offscreenContext.moveTo(pts[0].x, pts[0].y); - - // Render the stroke using the brush engine with explicit brush type - brushEngine.startStroke(pts[0].x, pts[0].y); - for (let i = 1; i < pts.length; i++) { - // Use drawWithType instead of draw to bypass state dependency - brushEngine.drawWithType( - pts[i].x, - pts[i].y, - drawing.lineWidth, - drawing.color, - drawing.brushType // Pass brush type directly - ); - } - offscreenContext.restore(); - } else { - if (drawing.brushType && drawing.brushType !== "normal") { - console.log("Advanced brush found but no brushEngine:", { - id: drawing.drawingId, - brushType: drawing.brushType, - hasBrushEngine: !!brushEngine - }); - } - // Default rendering for normal brush - offscreenContext.beginPath(); - offscreenContext.moveTo(pts[0].x, pts[0].y); - for (let i = 1; i < pts.length; i++) - offscreenContext.lineTo(pts[i].x, pts[i].y); - offscreenContext.strokeStyle = drawing.color; - offscreenContext.lineWidth = drawing.lineWidth; - offscreenContext.lineCap = drawing.brushStyle || "round"; - offscreenContext.lineJoin = drawing.brushStyle || "round"; - offscreenContext.stroke(); - } - } - } else if (drawing.pathData && drawing.pathData.tool === "shape") { - if (drawing.pathData.points) { - const pts = drawing.pathData.points; - offscreenContext.save(); - offscreenContext.beginPath(); - offscreenContext.moveTo(pts[0].x, pts[0].y); - for (let i = 1; i < pts.length; i++) - offscreenContext.lineTo(pts[i].x, pts[i].y); - offscreenContext.closePath(); - offscreenContext.fillStyle = drawing.color; - offscreenContext.fill(); - offscreenContext.restore(); - } else { - const { - type, - start, - end, - brushStyle: storedBrush, - } = drawing.pathData; - offscreenContext.save(); - offscreenContext.fillStyle = drawing.color; - offscreenContext.lineWidth = drawing.lineWidth; - if (type === "circle") { - const radius = Math.sqrt( - (end.x - start.x) ** 2 + (end.y - start.y) ** 2 - ); - offscreenContext.beginPath(); - offscreenContext.arc(start.x, start.y, radius, 0, Math.PI * 2); - offscreenContext.fill(); - } else if (type === "rectangle") { - offscreenContext.fillRect( - start.x, - start.y, - end.x - start.x, - end.y - start.y - ); - } else if (type === "hexagon") { - const radius = Math.sqrt( - (end.x - start.x) ** 2 + (end.y - start.y) ** 2 - ); - offscreenContext.beginPath(); - for (let i = 0; i < 6; i++) { - const angle = (Math.PI / 3) * i; - const xPoint = start.x + radius * Math.cos(angle); - const yPoint = start.y + radius * Math.sin(angle); - if (i === 0) offscreenContext.moveTo(xPoint, yPoint); - else offscreenContext.lineTo(xPoint, yPoint); - } - offscreenContext.closePath(); - offscreenContext.fill(); - } else if (type === "line") { - offscreenContext.beginPath(); - offscreenContext.moveTo(start.x, start.y); - offscreenContext.lineTo(end.x, end.y); - offscreenContext.strokeStyle = drawing.color; - offscreenContext.lineWidth = drawing.lineWidth; - const cap = storedBrush || drawing.brushStyle || "round"; - offscreenContext.lineCap = cap; - offscreenContext.lineJoin = cap; - offscreenContext.stroke(); - } - offscreenContext.restore(); - } - } else if (drawing.pathData && drawing.pathData.tool === "image") { - const { image, x, y, width, height } = drawing.pathData; - let img = new Image(); - img.src = image; - img.onload = () => { - offscreenContext.drawImage(img, x, y, width, height); - }; - } - } - if (!selectedUser) { - // Group users by 5-minute intervals - // Use both committed drawings and pending drawings so the UI's - // user/time-group list reflects the strokes the user currently sees. - const groupMap = {}; - const groupingSource = [ - ...(userData.drawings || []), - ...(pendingDrawings || []), - ]; - groupingSource.forEach((d) => { - try { - const ts = d.timestamp || d.order || 0; - const periodStart = - Math.floor(ts / (5 * 60 * 1000)) * (5 * 60 * 1000); - if (!groupMap[periodStart]) groupMap[periodStart] = new Set(); - if (d.user) groupMap[periodStart].add(d.user); - } catch (e) { } - }); - const groups = Object.keys(groupMap).map((k) => ({ - periodStart: parseInt(k), - users: Array.from(groupMap[k]), - })); - groups.sort((a, b) => b.periodStart - a.periodStart); - if (selectedUser && selectedUser !== "") { - let stillExists = false; - if (typeof selectedUser === "string") { - for (const g of groups) { - if (g.users.includes(selectedUser)) { - stillExists = true; - break; - } - } - } else if (typeof selectedUser === "object" && selectedUser.user) { - for (const g of groups) { - if ( - g.periodStart === selectedUser.periodStart && - g.users.includes(selectedUser.user) - ) { - stillExists = true; - break; - } - } - } - - if (!stillExists) { - try { - setSelectedUser(""); - } catch (e) { - /* swallow if setter changed */ - } - } - } - - setUserList(groups); - } - - // Apply filters as post-processing after all regular drawings are rendered - if (filterDrawings.length > 0) { - console.log("[drawAllDrawings] Applying", filterDrawings.length, "filter(s)"); - for (const filterDrawing of filterDrawings) { - try { - if (filterDrawing.filterType && filterDrawing.filterParams) { - const imageData = offscreenContext.getImageData(0, 0, canvasWidth, canvasHeight); - const filteredImageData = applyImageFilter( - imageData, - filterDrawing.filterType, - filterDrawing.filterParams - ); - offscreenContext.putImageData(filteredImageData, 0, 0); - console.log("[drawAllDrawings] Applied filter:", filterDrawing.filterType); - } - } catch (e) { - console.error("[drawAllDrawings] Error applying filter:", filterDrawing.filterType, e); - } - } - } - - // Copy offscreen canvas to visible canvas atomically - console.log("[drawAllDrawings] Copying offscreen canvas to visible canvas. Total strokes rendered:", regularDrawings.length, "filters:", filterDrawings.length); - context.imageSmoothingEnabled = false; - context.clearRect(0, 0, canvasWidth, canvasHeight); - context.drawImage(offscreenCanvasRef.current, 0, 0); - console.log("[drawAllDrawings] Canvas update complete"); - } catch (e) { - console.error("Error in drawAllDrawings:", e); - } finally { - // Restore current brush state - if (brushEngine && savedBrushType) { - brushEngine.setBrushType(savedBrushType); - if (savedBrushParams) { - brushEngine.setBrushParams(savedBrushParams); - } - } - setIsLoading(false); - isDrawingInProgressRef.current = false; - } - }; - - drawAllDrawingsRef.current = drawAllDrawings; - - const undo = async () => { - if (!editingEnabled) { - showLocalSnack("Undo is disabled in view-only mode."); - return; - } - if (undoStack.length === 0) return; - if (isRefreshing) { - showLocalSnack( - "Please wait for the canvas to refresh before undoing again." - ); - return; - } - try { - await undoAction({ - auth, - currentUser: auth?.username || "anonymous", - undoStack, - setUndoStack, - setRedoStack, - userData, - drawAllDrawings, - refreshCanvasButtonHandler: refreshCanvasButtonHandler, - roomId: currentRoomId, - }); - // After undo completes, refresh undo/redo availability from server - try { - await checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } catch (e) { } - updateFilterState(); // Update filter state after undo - } catch (error) { - console.error("Error during undo:", error); - } - }; - - const redo = async () => { - if (!editingEnabled) { - showLocalSnack("Redo is disabled in view-only mode."); - return; - } - if (redoStack.length === 0) return; - if (isRefreshing) { - showLocalSnack( - "Please wait for the canvas to refresh before redoing again." - ); - return; - } - try { - await redoAction({ - auth, - currentUser: auth?.username || "anonymous", - redoStack, - setRedoStack, - setUndoStack, - userData, - drawAllDrawings, - refreshCanvasButtonHandler: refreshCanvasButtonHandler, - roomId: currentRoomId, - }); - // After redo completes, refresh undo/redo availability from server - try { - await checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } catch (e) { } - updateFilterState(); // Update filter state after redo - } catch (error) { - console.error("Error during redo:", error); - } - }; - - // Register keyboard shortcuts and commands - useEffect(() => { - // Initialize shortcut manager - if (!shortcutManagerRef.current) { - shortcutManagerRef.current = new KeyboardShortcutManager(); - } - - const manager = shortcutManagerRef.current; - - // Register all commands with the command registry - const commands = [ - // Command Palette & Help - { - id: 'commands.palette', - label: 'Open Command Palette', - description: 'Quick access to all commands', - keywords: ['palette', 'search', 'find'], - category: 'Commands', - action: () => setCommandPaletteOpen(true), - shortcut: { key: 'k', modifiers: { ctrl: true } } - }, - { - id: 'commands.shortcuts', - label: 'Show Keyboard Shortcuts', - description: 'View all available keyboard shortcuts', - keywords: ['help', 'shortcuts', 'keys'], - category: 'Commands', - action: () => setShortcutsHelpOpen(true), - shortcut: { key: '/', modifiers: { ctrl: true } } - }, - { - id: 'commands.cancel', - label: 'Cancel / Escape', - description: 'Cancel current action or close dialogs', - keywords: ['cancel', 'escape', 'close'], - category: 'Commands', - action: () => { - if (commandPaletteOpen) setCommandPaletteOpen(false); - else if (shortcutsHelpOpen) setShortcutsHelpOpen(false); - else if (drawing) setDrawing(false); - }, - shortcut: { key: 'Escape', modifiers: {} } - }, - - // Edit Operations - { - id: 'edit.undo', - label: 'Undo', - description: 'Undo the last action', - keywords: ['undo', 'revert'], - category: 'Edit', - action: undo, - shortcut: { key: 'z', modifiers: { ctrl: true } }, - enabled: () => editingEnabled && undoStack.length > 0 - }, - { - id: 'edit.redo', - label: 'Redo', - description: 'Redo the last undone action', - keywords: ['redo', 'repeat'], - category: 'Edit', - action: redo, - shortcut: { key: 'z', modifiers: { ctrl: true, shift: true } }, - enabled: () => editingEnabled && redoStack.length > 0 - }, - - // Canvas Operations - { - id: 'canvas.clear', - label: 'Clear Canvas', - description: 'Remove all strokes from canvas', - keywords: ['clear', 'delete', 'reset'], - category: 'Canvas', - action: () => { - if (editingEnabled) { - setClearDialogOpen(true); - } else { - showLocalSnack('Canvas clearing is disabled in view-only mode'); - } - }, - shortcut: { key: 'k', modifiers: { ctrl: true, shift: true } }, - enabled: () => editingEnabled - }, - { - id: 'canvas.refresh', - label: 'Refresh Canvas', - description: 'Reload canvas from server', - keywords: ['refresh', 'reload'], - category: 'Canvas', - action: refreshCanvasButtonHandler, - shortcut: { key: 'r', modifiers: { ctrl: true } } - }, - { - id: 'canvas.settings', - label: 'Canvas Settings', - description: 'Open canvas settings', - keywords: ['settings', 'preferences'], - category: 'Canvas', - action: () => { - if (onOpenSettings) onOpenSettings(); - }, - shortcut: { key: ',', modifiers: { ctrl: true } }, - visible: () => !!onOpenSettings - }, - - // Tools - { - id: 'tool.pen', - label: 'Select Pen Tool', - description: 'Switch to freehand drawing', - keywords: ['pen', 'draw', 'brush'], - category: 'Tools', - action: () => { - if (editingEnabled) { - setDrawMode('freehand'); - showLocalSnack('Pen tool selected'); - } - }, - shortcut: { key: 'p', modifiers: {} }, - enabled: () => editingEnabled - }, - { - id: 'tool.eraser', - label: 'Select Eraser', - description: 'Switch to eraser mode', - keywords: ['eraser', 'erase', 'remove'], - category: 'Tools', - action: () => { - if (editingEnabled) { - setDrawMode('eraser'); - showLocalSnack('Eraser selected'); - } - }, - shortcut: { key: 'e', modifiers: {} }, - enabled: () => editingEnabled - }, - { - id: 'tool.rectangle', - label: 'Select Rectangle Tool', - description: 'Draw rectangles and squares', - keywords: ['rectangle', 'rect', 'square'], - category: 'Tools', - action: () => { - if (editingEnabled) { - setDrawMode('shape'); - setShapeType('rectangle'); - showLocalSnack('Rectangle tool selected'); - } - }, - shortcut: { key: 'r', modifiers: {} }, - enabled: () => editingEnabled - }, - { - id: 'tool.circle', - label: 'Select Circle Tool', - description: 'Draw circles and ellipses', - keywords: ['circle', 'oval', 'ellipse'], - category: 'Tools', - action: () => { - if (editingEnabled) { - setDrawMode('shape'); - setShapeType('circle'); - showLocalSnack('Circle tool selected'); - } - }, - shortcut: { key: 'c', modifiers: {} }, - enabled: () => editingEnabled - }, - { - id: 'tool.line', - label: 'Select Line Tool', - description: 'Draw straight lines', - keywords: ['line', 'straight'], - category: 'Tools', - action: () => { - if (editingEnabled) { - setDrawMode('shape'); - setShapeType('line'); - showLocalSnack('Line tool selected'); - } - }, - shortcut: { key: 'l', modifiers: {} }, - enabled: () => editingEnabled - } - ]; - - // Register commands with command registry - // Clear first to ensure clean state - commandRegistry.clear(); - - // Register each command (allowOverwrite for React re-renders) - commands.forEach(cmd => { - commandRegistry.register(cmd, { allowOverwrite: true }); - }); - - // Register keyboard shortcuts - manager.clear(); - commands.forEach(cmd => { - if (cmd.shortcut) { - manager.register( - cmd.shortcut.key, - cmd.shortcut.modifiers, - () => { - // Check if command is enabled before executing - if (cmd.enabled && !cmd.enabled()) { - return; - } - cmd.action(); - }, - cmd.label, - cmd.category - ); - } - }); - - // Add global keyboard event listener - const handleKeyDown = (event) => manager.handleKeyDown(event); - document.addEventListener('keydown', handleKeyDown); - - // Cleanup - return () => { - document.removeEventListener('keydown', handleKeyDown); - manager.clear(); - }; - }, [ - editingEnabled, - undoStack, - redoStack, - undo, - redo, - refreshCanvasButtonHandler, - onOpenSettings, - commandPaletteOpen, - shortcutsHelpOpen, - drawing - ]); - - const { - selectionStart, - setSelectionStart, - selectionRect, - setSelectionRect, - cutImageData, - setCutImageData, - handleCutSelection, - } = useCanvasSelection( - canvasRef, - currentUser, - userData, - generateId, - drawAllDrawings, - currentRoomId, - setUndoAvailable, - setRedoAvailable, - auth, - roomType, - showLocalSnack - ); - - // Draw a preview of a shape (for shape mode) - const drawShapePreview = (start, end, shape, color, lineWidth) => { - if (!start || !end) return; - - const canvas = canvasRef.current; - const context = canvas.getContext("2d"); - context.save(); - context.strokeStyle = color; - context.lineWidth = lineWidth; - context.setLineDash([5, 3]); - - if (shape === "circle") { - const radius = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2); - context.beginPath(); - context.arc(start.x, start.y, radius, 0, Math.PI * 2); - context.stroke(); - } else if (shape === "rectangle") { - context.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y); - } else if (shape === "hexagon") { - const radius = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2); - context.beginPath(); - - for (let i = 0; i < 6; i++) { - const angle = (Math.PI / 3) * i; - const xPoint = start.x + radius * Math.cos(angle); - const yPoint = start.y + radius * Math.sin(angle); - - if (i === 0) context.moveTo(xPoint, yPoint); - else context.lineTo(xPoint, yPoint); - } - context.closePath(); - context.stroke(); - } else if (shape === "line") { - context.beginPath(); - context.moveTo(start.x, start.y); - context.lineTo(end.x, end.y); - context.lineCap = brushStyle; - context.lineJoin = brushStyle; - context.stroke(); - } - - context.restore(); - }; - - // Handle paste action for cut selection - const handlePaste = async (e) => { - if (!editingEnabled) { - showLocalSnack("Editing is disabled in view-only mode."); - setDrawMode("freehand"); - return; - } - if ( - !cutImageData || - !Array.isArray(cutImageData) || - cutImageData.length === 0 - ) { - showLocalSnack("No cut selection available to paste."); - setDrawMode("freehand"); - return; - } - - const canvas = canvasRef.current; - const rectCanvas = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rectCanvas.width; - const scaleY = canvas.height / rectCanvas.height; - const pasteX = (e.clientX - rectCanvas.left) * scaleX; - const pasteY = (e.clientY - rectCanvas.top) * scaleY; - - let minX = Infinity, - minY = Infinity; - - cutImageData.forEach((drawing) => { - if (Array.isArray(drawing.pathData)) { - drawing.pathData.forEach((pt) => { - minX = Math.min(minX, pt.x); - minY = Math.min(minY, pt.y); - }); - } else if (drawing.pathData && drawing.pathData.tool === "shape") { - if (drawing.pathData.points && Array.isArray(drawing.pathData.points)) { - drawing.pathData.points.forEach((pt) => { - minX = Math.min(minX, pt.x); - minY = Math.min(minY, pt.y); - }); - } else if (drawing.pathData.type === "line") { - if (drawing.pathData.start) { - minX = Math.min(minX, drawing.pathData.start.x); - minY = Math.min(minY, drawing.pathData.start.y); - } - if (drawing.pathData.end) { - minX = Math.min(minX, drawing.pathData.end.x); - minY = Math.min(minY, drawing.pathData.end.y); - } - } - } - }); - - if (minX === Infinity || minY === Infinity) { - showLocalSnack("Invalid cut data."); - return; - } - - const offsetX = pasteX - minX; - const offsetY = pasteY - minY; - let pastedDrawings = []; - - const newDrawings = cutImageData - .map((originalDrawing) => { - let newPathData; - if (Array.isArray(originalDrawing.pathData)) { - newPathData = originalDrawing.pathData.map((pt) => ({ - x: pt.x + offsetX, - y: pt.y + offsetY, - })); - } else if ( - originalDrawing.pathData && - originalDrawing.pathData.tool === "shape" - ) { - if ( - originalDrawing.pathData.points && - Array.isArray(originalDrawing.pathData.points) - ) { - const newPoints = originalDrawing.pathData.points.map((pt) => ({ - x: pt.x + offsetX, - y: pt.y + offsetY, - })); - newPathData = { ...originalDrawing.pathData, points: newPoints }; - } else if (originalDrawing.pathData.type === "line") { - const newStart = { - x: originalDrawing.pathData.start.x + offsetX, - y: originalDrawing.pathData.start.y + offsetY, - }; - const newEnd = { - x: originalDrawing.pathData.end.x + offsetX, - y: originalDrawing.pathData.end.y + offsetY, - }; - newPathData = { - ...originalDrawing.pathData, - start: newStart, - end: newEnd, - }; - } - } else { - return null; - } - - // Preserve all metadata from original drawing - const metadata = { - brushStyle: originalDrawing.brushStyle, - brushType: originalDrawing.brushType, - brushParams: originalDrawing.brushParams, - drawingType: originalDrawing.drawingType, - stampData: originalDrawing.stampData, - stampSettings: originalDrawing.stampSettings, - filterType: originalDrawing.filterType, - filterParams: originalDrawing.filterParams, - }; - - return new Drawing( - generateId(), - originalDrawing.color, - originalDrawing.lineWidth, - newPathData, - Date.now(), - currentUser, - metadata - ); - }) - .filter(Boolean); - - setIsRefreshing(true); - setRedoStack([]); - - const pasteRecordId = generateId(); - showLocalSnack(`Pasting ${newDrawings.length} item(s)... Please wait.`); - console.log("[handlePaste] Starting paste operation:", { - pasteRecordId, - drawingCount: newDrawings.length, - drawingTypes: newDrawings.map(d => d.drawingType || "stroke") - }); - - // Attach parentPasteId to each new drawing so the backend/read path can filter them - for (const nd of newDrawings) { - nd.roomId = currentRoomId; - nd.parentPasteId = pasteRecordId; - if (!nd.pathData) nd.pathData = {}; - nd.pathData.parentPasteId = pasteRecordId; - } - console.log("[handlePaste] Attached parentPasteId to all drawings:", pasteRecordId); - - // Submit all pasted drawings as replacement/child strokes but DO NOT add each to the undo stack - let submittedCount = 0; - for (const newDrawing of newDrawings) { - try { - userData.addDrawing(newDrawing); - - await submitToDatabase( - newDrawing, - auth, - { roomId: currentRoomId, roomType, skipUndoStack: true }, - setUndoAvailable, - setRedoAvailable - ); - pastedDrawings.push(newDrawing); - submittedCount++; - - showLocalSnack(`Pasting... ${submittedCount}/${newDrawings.length} items saved.`); - } catch (error) { - console.error("Failed to save drawing:", newDrawing, error); - handleAuthError(error); - } - } - - const pastedIds = pastedDrawings.map((d) => d.drawingId); - const pasteRecord = new Drawing( - pasteRecordId, - "#FFFFFF", - 1, - { tool: "paste", cut: false, pastedDrawingIds: pastedIds }, - Date.now(), - currentUser - ); - console.log("[handlePaste] Created paste record:", { - pasteRecordId, - pastedCount: pastedIds.length, - pastedIds: pastedIds.join(',') - }); - try { - // Submit the single paste-record (counts as one backend undo operation) - await submitToDatabase( - pasteRecord, - auth, - { roomId: currentRoomId, roomType }, - setUndoAvailable, - setRedoAvailable - ); - console.log("[handlePaste] Paste record submitted successfully"); - setUndoStack((prev) => [ - ...prev, - { type: "paste", pastedDrawings: pastedDrawings, backendCount: 1 }, - ]); - } catch (error) { - console.error("Failed to save paste record:", pasteRecord, error); - showLocalSnack("Paste failed to persist. Some strokes may be missing."); - } - - setIsRefreshing(false); - - // Update undo/redo availability after paste operations - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } - - tempPathRef.current = []; - if (pastedDrawings.length === newDrawings.length) { - drawAllDrawings(); - setCutImageData([]); - setDrawMode("freehand"); - showLocalSnack(`Paste completed! ${pastedDrawings.length} item(s) pasted successfully.`); - } else { - showLocalSnack(`Paste partially completed. ${pastedDrawings.length}/${newDrawings.length} items pasted.`); - } - }; - - const mergedRefreshCanvas = async (sourceLabel = undefined) => { - try { - if (sourceLabel) { - console.log('mergedRefreshCanvas called from:', sourceLabel, '==='); - console.debug('mergedRefreshCanvas called from:', sourceLabel); - } else { - console.log('mergedRefreshCanvas called (no label) ==='); - console.debug('mergedRefreshCanvas called'); - } - } catch (e) { } - // If currently panning, defer refresh until pan ends to avoid races and frequent backend calls. - try { - if (isPanning) { - console.debug( - "[mergedRefreshCanvas] deferring because isPanning=true, marking pendingPanRefreshRef" - ); - pendingPanRefreshRef.current = true; - return; - } - } catch (e) { } - - if (sourceLabel === "undo-event" || sourceLabel === "redo-event") { - console.log("[mergedRefreshCanvas] Forcing complete state reset for undo/redo"); - lastDrawnStateRef.current = null; - } - - setIsLoading(true); - const backendCount = await backendRefreshCanvas( - serverCountRef.current, - userData, - drawAllDrawings, - historyRange ? historyRange.start : undefined, - historyRange ? historyRange.end : undefined, - { - roomId: currentRoomId, - auth, - clearLastDrawnState: () => { - console.log("[mergedRefreshCanvas] Clearing lastDrawnStateRef to force redraw"); - lastDrawnStateRef.current = null; - } - } - ); - - const pendingSnapshot = [...pendingDrawings]; - - // Don't clear all pending drawings, only mark confirmed ones for removal - - serverCountRef.current = backendCount; - // Re-append any pending drawings that the backend didn't return. - const drawingMatches = (a, b) => { - if (!a || !b) return false; - if (a.drawingId && b.drawingId && a.drawingId === b.drawingId) - return true; - - try { - const sameUser = a.user === b.user; - const tsA = a.timestamp || a.ts || 0; - const tsB = b.timestamp || b.ts || 0; - const tsClose = Math.abs(tsA - tsB) < 1000; - const lenA = Array.isArray(a.pathData) - ? a.pathData.length - : a.pathData && a.pathData.points - ? a.pathData.points.length - : 0; - const lenB = Array.isArray(b.pathData) - ? b.pathData.length - : b.pathData && b.pathData.points - ? b.pathData.points.length - : 0; - const lenClose = Math.abs(lenA - lenB) <= 1; - return sameUser && tsClose && lenClose; - } catch (e) { - return false; - } - }; - - try { - const cutOriginalIds = new Set(); - (userData.drawings || []).forEach((d) => { - if ( - d.pathData && - d.pathData.tool === "cut" && - Array.isArray(d.pathData.originalStrokeIds) - ) { - d.pathData.originalStrokeIds.forEach((id) => cutOriginalIds.add(id)); - } - }); - - if (cutOriginalIds.size > 0) { - userData.drawings = (userData.drawings || []).filter( - (d) => !cutOriginalIds.has(d.drawingId) - ); - } - } catch (e) { - // best-effort - } - - // Re-append pending drawings that the backend didn't return, but - // skip any pending items older than the authoritative clearedAt timestamp - const clearedAt = currentRoomId - ? roomClearedAtRef.current[currentRoomId] - : null; - const stillPending = []; - - pendingSnapshot.forEach((pd) => { - try { - const pdTs = pd.timestamp || pd.ts || 0; - if (clearedAt && pdTs < clearedAt) { - // This pending drawing was created before a server clear; ignore it - return; - } - } catch (e) { } - - const exists = userData.drawings.find((d) => drawingMatches(d, pd)); - if (!exists) { - // Backend doesn't have it yet, keep it pending - userData.drawings.push(pd); - stillPending.push(pd); - } else { - // If pending drawing has stampData but backend version doesn't, use pending version - if (pd.drawingType === "stamp" && pd.stampData) { - const backendMatch = exists; - if (!backendMatch.stampData || !backendMatch.stampData.image && pd.stampData.image) { - console.warn("Backend stamp missing stampData, using pending version:", { - drawingId: pd.drawingId, - pendingHasStampData: !!pd.stampData, - backendHasStampData: !!backendMatch.stampData, - pendingImageLength: pd.stampData.image ? pd.stampData.image.length : 0, - backendImageLength: backendMatch.stampData && backendMatch.stampData.image ? backendMatch.stampData.image.length : 0 - }); - - // Replace backend version with pending version that has complete data - const idx = userData.drawings.findIndex((d) => drawingMatches(d, pd)); - if (idx !== -1) { - userData.drawings[idx] = pd; - } - } - } - - // Backend has it, mark as confirmed and remove from pending - if (pd.drawingId) { - confirmedStrokesRef.current.add(pd.drawingId); - } - } - }); - - // Update pending drawings to only include those still not confirmed by backend - setPendingDrawings(stillPending); - - // CRITICAL: Deduplicate filters - only keep the LATEST of each filter type - // This prevents stacking when backend returns duplicates - const filtersByType = new Map(); - const nonFilterDrawings = []; - - (userData.drawings || []).forEach((drawing) => { - if (drawing.drawingType === "filter" && drawing.filterType) { - const existing = filtersByType.get(drawing.filterType); - // Keep the one with the latest timestamp - if (!existing || (drawing.timestamp || 0) > (existing.timestamp || 0)) { - filtersByType.set(drawing.filterType, drawing); - } - } else { - nonFilterDrawings.push(drawing); - } - }); - - // Rebuild drawings array with deduplicated filters - const deduplicatedDrawings = [ - ...nonFilterDrawings, - ...Array.from(filtersByType.values()) - ]; - - console.log(`[mergedRefreshCanvas] Deduplicated filters. Filter count: ${filtersByType.size}, Total drawings: ${deduplicatedDrawings.length}`); - - // CRITICAL: Update both the mutable userData object AND React state - // Update userData in place so the closure reference works - userData.drawings = deduplicatedDrawings; - - // Also update React state to trigger re-renders - const newUserData = new UserData(userData.userId, userData.username); - newUserData.drawings = deduplicatedDrawings; - setUserData(newUserData); - - // Extract custom stamps from all drawings and update stamp panel - extractCustomStamps(); - - // Use requestAnimationFrame for smoother rendering - requestAnimationFrame(() => { - drawAllDrawings(); - setIsLoading(false); - updateFilterState(); // Update filter state after loading drawings - }); - }; - - // Extract custom stamps from backend drawings and update StampPanel - const extractCustomStamps = () => { - try { - const customStamps = []; - const seenStamps = new Map(); // Deduplicate by image content or emoji - - (userData.drawings || []).forEach((drawing) => { - if (drawing.drawingType === "stamp" && drawing.stampData) { - const stamp = drawing.stampData; - - // Skip default emoji stamps (they're already in StampPanel) - if (stamp.emoji && !stamp.image) { - return; - } - - // For custom image stamps, create a unique key based on image content - if (stamp.image) { - const imageKey = stamp.image.substring(0, 100); // Use first 100 chars as key - - if (!seenStamps.has(imageKey)) { - seenStamps.set(imageKey, true); - customStamps.push({ - id: `stamp-${Date.now()}-${customStamps.length}`, - name: stamp.name || 'Custom Stamp', - category: stamp.category || 'custom', - image: stamp.image, - emoji: stamp.emoji - }); - } - } - } - }); - - if (customStamps.length > 0) { - console.log('Extracted custom stamps from backend:', customStamps.length); - setBackendStamps(customStamps); - } - } catch (error) { - console.error('Error extracting custom stamps:', error); - } - }; - - const startDrawingHandler = (e) => { - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - if (e.button === 1) { - // Middle mouse button: start panning - setIsPanning(true); - panStartRef.current = { x: e.clientX, y: e.clientY }; - panOriginRef.current = { ...panOffset }; - setIsLoading(true); - // Throttle pan-triggered refreshes: if we recently refreshed, defer until pan end - try { - const now = Date.now(); - const diff = now - panLastRefreshRef.current; - console.debug( - `[pan] now=${now} lastRefresh=${panLastRefreshRef.current} diff=${diff} cooldown=${PAN_REFRESH_COOLDOWN_MS}` - ); - if (diff > PAN_REFRESH_COOLDOWN_MS) { - panLastRefreshRef.current = now; - console.debug("[pan] triggering immediate mergedRefreshCanvas"); - mergedRefreshCanvas("pan-start").finally(() => setIsLoading(false)); - } else { - // Mark that we skipped the immediate refresh and schedule a deferred refresh on mouseup - panRefreshSkippedRef.current = true; - console.debug( - "[pan] skipped immediate refresh; scheduling deferred refresh on mouseup" - ); - if (panEndRefreshTimerRef.current) - clearTimeout(panEndRefreshTimerRef.current); - panEndRefreshTimerRef.current = setTimeout(() => { - if (panRefreshSkippedRef.current) { - panRefreshSkippedRef.current = false; - panLastRefreshRef.current = Date.now(); - console.debug("[pan] deferred timer firing mergedRefreshCanvas"); - mergedRefreshCanvas("pan-deferred").finally(() => - setIsLoading(false) - ); - } - panEndRefreshTimerRef.current = null; - }, Math.max(200, PAN_REFRESH_COOLDOWN_MS - diff)); - setIsLoading(false); - } - } catch (e) { - mergedRefreshCanvas().finally(() => setIsLoading(false)); - } - return; - } - - if (!editingEnabled) return; - - if (drawMode === "eraser" || drawMode === "freehand") { - const context = canvas.getContext("2d"); - context.strokeStyle = color; - context.lineWidth = lineWidth; - context.lineCap = brushStyle; - context.lineJoin = brushStyle; - - // Initialize brush engine for advanced brushes - if (brushEngine) { - brushEngine.updateContext(context); - brushEngine.startStroke(x, y); - - // For normal brush, we still need the standard path setup - if (currentBrushType === "normal") { - context.beginPath(); - context.moveTo(x, y); - } - } else { - // Fallback if no brush engine - context.beginPath(); - context.moveTo(x, y); - } - - tempPathRef.current = [{ x, y }]; - setDrawing(true); - } else if (drawMode === "shape") { - setShapeStart({ x, y }); - setDrawing(true); - - const dataURL = canvas.toDataURL(); - let snapshotImg = new Image(); - - snapshotImg.src = dataURL; - snapshotRef.current = snapshotImg; - } else if (drawMode === "select") { - setSelectionStart({ x, y }); - setSelectionRect(null); - setDrawing(true); - - const dataURL = canvas.toDataURL(); - let snapshotImg = new Image(); - - snapshotImg.src = dataURL; - snapshotRef.current = snapshotImg; - } else if (drawMode === "paste") { - handlePaste(e); - } else if (drawMode === "stamp") { - // Start stamp preview on mousedown (will place on mouseup) - if (selectedStamp && stampSettings) { - setStampPreview({ x, y, stamp: selectedStamp, settings: stampSettings }); - stampPreviewRef.current = { x, y, stamp: selectedStamp, settings: stampSettings }; - setDrawing(true); // Enable dragging - } - } - }; - - const handlePan = (e) => { - if (!isPanning) return; - - // If the middle button is no longer pressed, stop panning. - if (!(e.buttons & 4)) { - setIsPanning(false); - panOriginRef.current = { ...panOffset }; - return; - } - - const deltaX = e.clientX - panStartRef.current.x; - const deltaY = e.clientY - panStartRef.current.y; - let newX = panOriginRef.current.x + deltaX; - let newY = panOriginRef.current.y + deltaY; - const containerWidth = window.innerWidth; - const containerHeight = window.innerHeight; - - // Calculate minimum allowed offsets so that the canvas edge is not exceeded. - // Our canvas is fixed at canvasWidth and canvasHeight. - const minX = containerWidth - canvasWidth; // This will be negative if canvasWidth > containerWidth - const minY = containerHeight - canvasHeight; - - newX = clamp(newX, minX, 0); - newY = clamp(newY, minY, 0); - - setPanOffset({ - x: newX, - y: newY, - }); - }; - - const drawHandler = (e) => { - if (isPanning) { - handlePan(e); - return; - } - if (!editingEnabled) return; // prevent drawing but allow other handlers like panning to proceed - if (!drawing) return; - - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - console.log( - "Drawing with brush type:", - currentBrushType, - "drawMode:", - drawMode - ); - - // Update stamp preview position during drag - if (drawMode === "stamp" && stampPreviewRef.current) { - setStampPreview({ ...stampPreviewRef.current, x, y }); - stampPreviewRef.current = { ...stampPreviewRef.current, x, y }; - return; - } - - if (drawMode === "eraser" || drawMode === "freehand") { - const context = canvas.getContext("2d"); - - // Use advanced brush engine if available - if (brushEngine && currentBrushType !== "normal") { - console.log("Drawing with advanced brush engine:", currentBrushType); - // Ensure context is up to date - brushEngine.updateContext(context); - - // Ensure brush engine has current state - if (brushEngine.brushType !== currentBrushType) { - brushEngine.setBrushType(currentBrushType); - } - if ( - JSON.stringify(brushEngine.brushParams) !== - JSON.stringify(brushParams) - ) { - brushEngine.setBrushParams(brushParams); - } - - brushEngine.draw(x, y, lineWidth, color); - } else { - console.log("Drawing with normal brush"); - // Default drawing behavior - context.lineTo(x, y); - context.stroke(); - context.beginPath(); - context.moveTo(x, y); - } - - tempPathRef.current.push({ x, y }); - } else if (drawMode === "shape" && drawing) { - // update shape preview with adjusted coordinates - if (snapshotRef.current && snapshotRef.current.complete) { - const context = canvas.getContext("2d"); - context.clearRect(0, 0, canvasWidth, canvasHeight); - context.drawImage(snapshotRef.current, 0, 0); - } - - drawShapePreview(shapeStart, { x, y }, shapeType, color, lineWidth); - } else if (drawMode === "select" && drawing) { - setSelectionRect({ start: selectionStart, end: { x, y } }); - - if (snapshotRef.current && snapshotRef.current.complete) { - const context = canvas.getContext("2d"); - context.clearRect(0, 0, canvasWidth, canvasHeight); - context.drawImage(snapshotRef.current, 0, 0); - } - - const context = canvas.getContext("2d"); - context.save(); - context.strokeStyle = "blue"; - context.lineWidth = 1; - context.setLineDash([6, 3]); - - const s = selectionStart; - const selX = Math.min(s.x, x); - const selY = Math.min(s.y, y); - const selWidth = Math.abs(x - s.x); - const selHeight = Math.abs(y - s.y); - - context.strokeRect(selX, selY, selWidth, selHeight); - context.restore(); - } - }; - - const stopDrawingHandler = async (e) => { - if (isPanning && e.button === 1) { - setIsPanning(false); - return; - } - if (!drawing) return; - setDrawing(false); - - if (!editingEnabled) { - tempPathRef.current = []; - return; - } - - snapshotRef.current = null; - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const finalX = e.clientX - rect.left; - const finalY = e.clientY - rect.top; - - if (drawMode === "eraser" || drawMode === "freehand") { - const newDrawing = new Drawing( - `drawing_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - color, - lineWidth, - tempPathRef.current, - Date.now(), - currentUser, - { - brushStyle: brushStyle, - brushType: currentBrushType, - brushParams: brushParams, - drawingType: "stroke", - } - ); - newDrawing.roomId = currentRoomId; - newDrawing.brushType = currentBrushType; - newDrawing.brushParams = brushParams; - - setUndoStack((prev) => [...prev, newDrawing]); - setRedoStack([]); - - try { - userData.addDrawing(newDrawing); - // Add to pending drawings for immediate display (optimistic UI) - setPendingDrawings((prev) => [...prev, newDrawing]); - - // Use requestAnimationFrame for immediate, smooth redraw - requestAnimationFrame(() => { - drawAllDrawings(); - }); - - // Queue the submission instead of submitting immediately - const submitTask = async () => { - try { - console.log("Submitting queued stroke:", { - drawingId: newDrawing.drawingId, - pathLength: tempPathRef.current.length, - }); - - await submitToDatabase( - newDrawing, - auth, - { - roomId: currentRoomId, - roomType, - }, - setUndoAvailable, - setRedoAvailable - ); - - // Don't remove from pending here - let mergedRefreshCanvas or socket confirmation handle it - - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } - } catch (error) { - console.error("Error during queued freehand submission:", error); - // On error, remove the failed stroke from pending - setPendingDrawings((prev) => - prev.filter((d) => d.drawingId !== newDrawing.drawingId) - ); - handleAuthError(error); - } - }; - - submissionQueueRef.current.push(submitTask); - processSubmissionQueue(); - } catch (error) { - console.error("Error preparing freehand stroke:", error); - handleAuthError(error); - } finally { - setIsRefreshing(false); - } - tempPathRef.current = []; - } else if (drawMode === "shape") { - if (!shapeStart) { - return; - } - - const finalEnd = { x: finalX, y: finalY }; - const context = canvas.getContext("2d"); - - context.save(); - context.fillStyle = color; - context.lineWidth = lineWidth; - context.setLineDash([]); - if (shapeType === "circle") { - const radius = Math.sqrt( - (finalEnd.x - shapeStart.x) ** 2 + (finalEnd.y - shapeStart.y) ** 2 - ); - - context.beginPath(); - context.arc(shapeStart.x, shapeStart.y, radius, 0, Math.PI * 2); - context.fill(); - } else if (shapeType === "rectangle") { - context.fillRect( - shapeStart.x, - shapeStart.y, - finalEnd.x - shapeStart.x, - finalEnd.y - shapeStart.y - ); - } else if (shapeType === "hexagon") { - const radius = Math.sqrt( - (finalEnd.x - shapeStart.x) ** 2 + (finalEnd.y - shapeStart.y) ** 2 - ); - context.beginPath(); - for (let i = 0; i < 6; i++) { - const angle = (Math.PI / 3) * i; - const xPoint = shapeStart.x + radius * Math.cos(angle); - const yPoint = shapeStart.y + radius * Math.sin(angle); - - if (i === 0) context.moveTo(xPoint, yPoint); - else context.lineTo(xPoint, yPoint); - } - - context.closePath(); - context.fill(); - } else if (shapeType === "line") { - context.beginPath(); - context.moveTo(shapeStart.x, shapeStart.y); - context.lineTo(finalEnd.x, finalEnd.y); - context.strokeStyle = color; - context.lineWidth = lineWidth; - context.lineCap = brushStyle; - context.lineJoin = brushStyle; - context.stroke(); - } - context.restore(); - - const shapeDrawingData = { - tool: "shape", - type: shapeType, - start: shapeStart, - end: finalEnd, - brushStyle: shapeType === "line" ? brushStyle : undefined, - }; - - const newDrawing = new Drawing( - `drawing_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - color, - lineWidth, - shapeDrawingData, - Date.now(), - currentUser, - { - brushStyle: shapeType === "line" ? brushStyle : "round", - brushType: currentBrushType, - brushParams: brushParams, - drawingType: "shape", - } - ); - newDrawing.roomId = currentRoomId; - - userData.addDrawing(newDrawing); - setPendingDrawings((prev) => [...prev, newDrawing]); - - // Use requestAnimationFrame for smooth shape rendering - requestAnimationFrame(() => { - drawAllDrawings(); - }); - - setUndoStack((prev) => [...prev, newDrawing]); - setRedoStack([]); - - // Queue the submission - const submitTask = async () => { - try { - await submitToDatabase( - newDrawing, - auth, - { - roomId: currentRoomId, - roomType, - }, - setUndoAvailable, - setRedoAvailable - ); - - // Don't remove from pending here - let mergedRefreshCanvas or socket confirmation handle it - - // Update undo/redo availability after shape submission - if (currentRoomId) { - checkUndoRedoAvailability( - auth, - setUndoAvailable, - setRedoAvailable, - currentRoomId - ); - } - } catch (error) { - console.error("Error during queued shape submission:", error); - // On error, remove the failed stroke from pending - setPendingDrawings((prev) => - prev.filter((d) => d.drawingId !== newDrawing.drawingId) - ); - handleAuthError(error); - } - }; - - submissionQueueRef.current.push(submitTask); - processSubmissionQueue(); - - setShapeStart(null); - } else if (drawMode === "select") { - setDrawing(false); - - try { - await mergedRefreshCanvas(); - } catch (error) { - console.error("Error during select submission or refresh:", error); - } finally { - setIsRefreshing(false); - } - - mergedRefreshCanvas(); - } else if (drawMode === "stamp" && stampPreviewRef.current) { - // Place stamp at final position on mouseup - const { x, y, stamp, settings } = stampPreviewRef.current; - await placeStamp(x, y, stamp, settings); - - // Clear preview - setStampPreview(null); - stampPreviewRef.current = null; - } - }; - - const openHistoryDialog = () => { - setSelectedUser(""); - setHistoryDialogOpen(true); - }; - - const handleApplyHistory = async (startMs, endMs) => { - // startMs and endMs are epoch ms. If not provided, read from inputs. - const start = - startMs !== undefined - ? startMs - : historyStartInput - ? new Date(historyStartInput).getTime() - : NaN; - const end = - endMs !== undefined - ? endMs - : historyEndInput - ? new Date(historyEndInput).getTime() - : NaN; - - if (isNaN(start) || isNaN(end)) { - showLocalSnack( - "Please select both start and end date/time before applying History Recall." - ); - return; - } - if (start > end) { - showLocalSnack("Invalid time range selected. Make sure start <= end."); - return; - } - - // Deselect any selected user when entering history recall - setSelectedUser(""); - setHistoryRange({ start, end }); - setIsLoading(true); - - // Try to load drawings for the requested time range - await clearCanvasForRefresh(); - // set a temporary historyRange so mergedRefreshCanvas will use it - setHistoryRange({ start, end }); - try { - const backendCount = await backendRefreshCanvas( - serverCountRef.current, - userData, - drawAllDrawings, - start, - end, - { roomId: currentRoomId, auth } - ); - serverCountRef.current = backendCount; - // If no drawings loaded, inform user and rollback historyRange - if (!userData.drawings || userData.drawings.length === 0) { - setHistoryRange(null); - showLocalSnack( - "No drawings were found in that date/time range. Please select another range or exit history recall mode." - ); - return; - } - setHistoryMode(true); - setHistoryDialogOpen(false); - } catch (e) { - console.error("Error applying history range:", e); - setHistoryRange(null); - showLocalSnack( - "An error occurred while loading history. See console for details." - ); - } finally { - setIsLoading(false); - } - }; - - // Auto-refresh when the active room changes - useEffect(() => { - // wipe local cache so we don't flash previous room's strokes - userData.drawings = []; - setIsRefreshing(true); - - // clear what's on screen immediately - try { - if (canvasRef.current) { - const ctx = canvasRef.current.getContext("2d"); - if (ctx) { - ctx.clearRect( - 0, - 0, - canvasRef.current.width, - canvasRef.current.height - ); - } - drawAllDrawings(); - } - } catch { } - - // reload for the new room - (async () => { - try { - await mergedRefreshCanvas(); // already room-aware - } finally { - setIsRefreshing(false); - } - })(); - }, [currentRoomId, canvasRefreshTrigger]); - - const exitHistoryMode = async () => { - // Deselect any selected user when leaving history mode - setSelectedUser(""); - setHistoryMode(false); - setHistoryRange(null); - setIsLoading(true); - try { - await clearCanvasForRefresh(); - serverCountRef.current = await backendRefreshCanvas( - serverCountRef.current, - userData, - drawAllDrawings, - undefined, - undefined, - { roomId: currentRoomId, auth } - ); - } finally { - setIsLoading(false); - } - }; - - const clearCanvas = async () => { - if (!editingEnabled) { - showLocalSnack("Cannot clear canvas in view-only mode."); - return; - } - const canvas = canvasRef.current; - const context = canvas.getContext("2d"); - - context.clearRect(0, 0, canvasWidth, canvasHeight); - - setUserData(initializeUserData()); - setUndoStack([]); - setRedoStack([]); - setPendingDrawings([]); - serverCountRef.current = 0; - }; - - const handleExportCanvas = async () => { - if (!currentRoomId) { - showLocalSnack("Cannot export: not in a room"); - return; - } - - try { - setIsLoading(true); - showLocalSnack("Exporting canvas data..."); - - const { exportRoomCanvas } = await import('../api/rooms'); - console.log('[Export] Calling API with roomId:', currentRoomId); - console.log('[Export] Auth token present:', !!auth?.token); - - const exportData = await exportRoomCanvas(auth?.token, currentRoomId); - - console.log('[Export] Received exportData:', { - exists: !!exportData, - type: typeof exportData, - keys: exportData ? Object.keys(exportData) : [], - hasStrokes: exportData ? !!exportData.strokes : false, - strokeCount: exportData ? exportData.strokeCount : 'N/A' - }); - - if (!exportData) { - console.error('[Export] exportData is null or undefined'); - showLocalSnack("Export failed: no data returned from server"); - return; - } - - if (!exportData.strokes) { - console.error('[Export] exportData.strokes is missing:', exportData); - showLocalSnack(`Export failed: no strokes in response (got ${exportData.strokeCount || 0} count)`); - return; - } - - // Create a downloadable JSON file - const dataStr = JSON.stringify(exportData, null, 2); - const dataBlob = new Blob([dataStr], { type: 'application/json' }); - const url = URL.createObjectURL(dataBlob); - const link = document.createElement('a'); - link.href = url; - link.download = `${exportData.roomName || 'canvas'}_export_${Date.now()}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - showLocalSnack(`Exported ${exportData.strokeCount} strokes successfully`); - console.log('[Export] Success - downloaded file'); - } catch (error) { - console.error("[Export] Error caught:", error); - console.error("[Export] Error stack:", error.stack); - showLocalSnack(`Export failed: ${error.message || 'Unknown error'}`); - } finally { - setIsLoading(false); - } - }; - - const handleImportCanvas = async () => { - if (!currentRoomId) { - showLocalSnack("Cannot import: not in a room"); - return; - } - - if (!editingEnabled) { - showLocalSnack("Cannot import in view-only mode"); - return; - } - - // Create a file input element - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'application/json,.json'; - - input.onchange = async (e) => { - const file = e.target.files[0]; - if (!file) return; - - try { - setIsLoading(true); - showLocalSnack("Reading import file..."); - - const text = await file.text(); - const importData = JSON.parse(text); - - if (!importData.strokes || !Array.isArray(importData.strokes)) { - showLocalSnack("Invalid import file: missing strokes array"); - return; - } - - // Ask user if they want to clear existing canvas - const clearExisting = window.confirm( - `Import ${importData.strokes.length} strokes?\n\n` + - `Click OK to replace current canvas, or Cancel to merge with existing drawings.` - ); - - showLocalSnack(`Importing ${importData.strokes.length} strokes...`); - - const { importRoomCanvas } = await import('../api/rooms'); - const result = await importRoomCanvas(auth?.token, currentRoomId, importData, clearExisting); - - if (result.status === 'success') { - showLocalSnack( - `Import complete: ${result.imported} imported, ${result.failed} failed`, - 6000 - ); - - // Refresh canvas to show imported data - setTimeout(async () => { - try { - await clearCanvasForRefresh(); - await mergedRefreshCanvas("post-import"); - } catch (error) { - console.error("Error refreshing after import:", error); - } - }, 500); - } else { - showLocalSnack(`Import failed: ${result.message || 'Unknown error'}`); - } - } catch (error) { - console.error("Import error:", error); - showLocalSnack(`Import failed: ${error.message || 'Invalid file format'}`); - } finally { - setIsLoading(false); - } - }; - - input.click(); - }; - - const toggleColorPicker = (event) => { - const viewportHeight = window.innerHeight; - const pickerHeight = 350; - const rect = event.target.getBoundingClientRect(); - const pickerElement = document.querySelector(".Canvas-color-picker"); - - setShowColorPicker(!showColorPicker); - - if (rect.bottom + pickerHeight > viewportHeight && pickerElement) { - pickerElement.classList.add("Canvas-color-picker--adjust-bottom"); - } else if (pickerElement) { - pickerElement.classList.remove("Canvas-color-picker--adjust-bottom"); - } - }; - - const closeColorPicker = () => { - setShowColorPicker(false); - }; - - useEffect(() => { - setIsRefreshing(true); - clearCanvasForRefresh(); - - mergedRefreshCanvas().then(() => { - setTimeout(() => { - setIsRefreshing(false); - }, 500); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedUser]); - - useEffect(() => { - setUndoAvailable(undoStack.length > 0); - setRedoAvailable(redoStack.length > 0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [undoStack, redoStack]); - - const [showToolbar, setShowToolbar] = useState(true); - const [hoverToolbar, setHoverToolbar] = useState(false); - - return ( -
- {/* Top header: room name + optional history range + exit button */} - - - {currentRoomName || "Master (not in a room)"} - - - {historyMode && historyRange && ( - - {new Date(historyRange.start).toLocaleString()} —{" "} - {new Date(historyRange.end).toLocaleString()} - - )} - - {currentRoomId && ( - - )} - - - {/* Archived overlay banner - visible when viewOnly (archived or explicit viewer) */} - {viewOnly && ( - - - - Archived — View Only - - {/* Owner-only destructive delete button placed under the banner */} - {isOwner && ( - - - - )} - - - )} - - {/* Wallet disconnected banner - visible when secure room wallet is not connected */} - {roomType === "secure" && !walletConnected && ( - - - - ⚠ Wallet Not Connected — Canvas Locked - - - - )} - - {/* Confirm Destructive Delete dialog (owner-only) */} - { - setConfirmDestructiveOpen(false); - setDestructiveConfirmText(""); - }} - > - Permanently delete room - - - This will permanently delete this room and all its data for every - user. This action is irreversible. - - - To confirm, type DELETE below. - - setDestructiveConfirmText(e.target.value)} - placeholder="Type DELETE to confirm" - sx={{ mt: 1 }} - /> - - - - - - - - - - {/* Stamp preview overlay */} - {stampPreview && ( - - {stampPreview.stamp.emoji ? ( - - {stampPreview.stamp.emoji} - - ) : stampPreview.stamp.image ? ( - Stamp preview - ) : null} - - )} - - setHoverToolbar(true)} - onMouseLeave={() => setHoverToolbar(false)} - > - setShowToolbar((v) => !v)} - sx={{ - position: "absolute", - right: showToolbar ? 0 : -20, - top: "50%", - transform: "translateY(-50%)", - - width: 20, - height: 60, - display: "flex", - alignItems: "center", - justifyContent: "center", - - opacity: hoverToolbar ? 1 : 0, - transition: "opacity 0.2s", - bgcolor: "rgba(0,0,0,0.2)", - cursor: "pointer", - zIndex: 1001, - }} - > - - {showToolbar ? ( - - ) : ( - - )} - - - { - if (!editingEnabled) { - showLocalSnack("Cut is disabled in view-only mode."); - return; - } - showLocalSnack("Cutting selection... This may take a moment."); - try { - const result = await handleCutSelection(); - if (result && result.compositeCutAction) { - setUndoStack((prev) => [...prev, result.compositeCutAction]); - } - setIsRefreshing(true); - showLocalSnack("Syncing cut operation..."); - try { - await mergedRefreshCanvas(); - showLocalSnack("Cut completed successfully!"); - } catch (e) { - console.error("Error syncing cut with server:", e); - showLocalSnack("Cut completed, but sync failed. Try refreshing."); - } finally { - setIsRefreshing(false); - } - } catch (e) { - console.error("Error during cut:", e); - showLocalSnack("Cut operation failed. Please try again."); - } - }} - cutImageData={cutImageData} - setClearDialogOpen={setClearDialogOpen} - /* Export/Import handlers */ - handleExportCanvas={handleExportCanvas} - handleImportCanvas={handleImportCanvas} - /* Advanced brush/stamp/filter props */ - currentBrushType={currentBrushType} - onBrushSelect={handleBrushSelect} - onBrushParamsChange={handleBrushParamsChange} - selectedStamp={selectedStamp} - onStampSelect={handleStampSelect} - onStampChange={handleStampChange} - backendStamps={backendStamps} - onFilterApply={applyFilter} - onFilterPreview={previewFilter} - onFilterUndo={undoFilter} - onClearAllFilters={clearAllFilters} - canUndoFilter={ - !!originalCanvasDataRef.current || - undoStack.some((drawing) => drawing.drawingType === "filter") - } - canClearFilters={hasFilters} - appliedFilters={ - (() => { - const filters = userData.drawings.filter((drawing) => drawing.drawingType === "filter"); - console.log(`[Canvas render] Passing ${filters.length} applied filters to Toolbar`, filters); - return filters; - })() - } - /* History Recall props (required so the toolbar can open/change/exit history mode) */ - openHistoryDialog={openHistoryDialog} - exitHistoryMode={exitHistoryMode} - historyMode={historyMode} - controlsDisabled={!editingEnabled} - onOpenSettings={onOpenSettings} - /> - - - {isRefreshing && ( -
-
-
- )} - - {/* History Recall Dialog */} - setHistoryDialogOpen(false)} - aria-labelledby="history-recall-dialog" - > - - History Recall - Select Date/Time Range - - - - Choose a start and end date/time to recall drawings from - ResilientDB. Only drawings within the selected range will be loaded. - - - setHistoryStartInput(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - setHistoryEndInput(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - - - - - - - - - - - - {historyMode - ? "History Mode Enabled — Canvas Editing Disabled" - : selectedUser && selectedUser !== "" - ? "Viewing Past Drawing History of Selected User — Canvas Editing Disabled" - : ""} - - - - - {/* Loading overlay: fades in/out while drawings load */} - - - - Loading Drawings... - - - - setClearDialogOpen(false)}> - Clear Canvas - - - Are you sure you want to clear the canvas for everyone? - - - - - - - - - {/* Command Palette - Quick command search and execution */} - setCommandPaletteOpen(false)} - commands={commandRegistry.getAll()} - onExecute={(command) => { - try { - command.action(); - } catch (error) { - console.error('[Canvas] Error executing command:', error); - showLocalSnack('Error executing command'); - } - }} - /> - - {/* Keyboard Shortcuts Help Dialog */} - setShortcutsHelpOpen(false)} - shortcuts={shortcutManagerRef.current?.getAllShortcuts() || []} - /> - - -
- ); -} - -export default Canvas; + console.error("[AI] submitAIDrawings failed:", error); + showLocalSnack(`AI drawing failed: ${error.message}`); + } + }; + + useEffect(() => { + const handleAIDrawingGenerated = async (event) => { + if (!event.detail) return; + await submitAIDrawings(event.detail); + }; + + window.addEventListener( + "rescanvas:ai-drawing-generated", + handleAIDrawingGenerated + ); + + return () => { + window.removeEventListener( + "rescanvas:ai-drawing-generated", + handleAIDrawingGenerated + ); + }; + }, []); + + useEffect(() => { + if (!auth?.token || !currentRoomId) return; + try { + setSocketToken(auth.token); + } catch (e) { } + + const socket = getSocket(auth.token); + + try { + socket.emit("join_room", { roomId: currentRoomId, token: auth?.token }); + } catch (e) { + socket.emit("join_room", { roomId: currentRoomId }); + } + + const scheduleRefresh = (delay = 300) => { + try { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + } catch (e) { } + refreshTimerRef.current = setTimeout(() => { + mergedRefreshCanvas().catch((e) => + console.error("Error during scheduled refresh:", e) + ); + refreshTimerRef.current = null; + }, delay); + }; + + const handleNewStroke = (data) => { + try { + const myName = getUsername(auth); + if (data.user === myName) { + // This is confirmation of our own stroke + const stroke = data.stroke; + if (stroke && stroke.drawingId) { + confirmedStrokesRef.current.add(stroke.drawingId); + } + return; + } + } catch (e) { + try { + const user = getAuthUser(auth) || {}; + if (data.user === user.username) { + // This is confirmation of our own stroke + const stroke = data.stroke; + if (stroke && stroke.drawingId) { + confirmedStrokesRef.current.add(stroke.drawingId); + } + return; + } + } catch (e2) { } + } + + const stroke = data.stroke; + + // Extract metadata for advanced features (stamps, brushes, filters) + const metadata = { + brushStyle: stroke.brushStyle, + brushType: stroke.brushType, + brushParams: stroke.brushParams, + drawingType: stroke.drawingType, + stampData: stroke.stampData, + stampSettings: stroke.stampSettings, + filterType: stroke.filterType, + filterParams: stroke.filterParams, + }; + + const drawing = new Drawing( + stroke.drawingId || + `remote_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, + stroke.color || "#000000", + stroke.lineWidth || 5, + stroke.pathData || [], + stroke.ts || stroke.timestamp || Date.now(), + stroke.user || "Unknown", + metadata + ); + + try { + const clearedAt = roomClearedAtRef.current[currentRoomId]; + if ( + clearedAt && + (drawing.timestamp || drawing.ts || Date.now()) < clearedAt + ) { + return; + } + } catch (e) { } + + setPendingDrawings((prev) => [...prev, drawing]); + + // If this is a custom stamp, add it to the stamp panel + if (drawing.drawingType === "stamp" && drawing.stampData && drawing.stampData.image) { + setBackendStamps((prevStamps) => { + const imageKey = drawing.stampData.image.substring(0, 100); + const alreadyExists = prevStamps.some(s => + s.image && s.image.substring(0, 100) === imageKey + ); + + if (!alreadyExists) { + console.log('Adding new custom stamp from Socket.IO:', drawing.stampData.name || 'Custom Stamp'); + return [...prevStamps, { + id: `stamp-${Date.now()}-${prevStamps.length}`, + name: drawing.stampData.name || 'Custom Stamp', + category: drawing.stampData.category || 'custom', + image: drawing.stampData.image, + emoji: drawing.stampData.emoji + }]; + } + return prevStamps; + }); + } + + // Use requestAnimationFrame for smoother rendering + requestAnimationFrame(() => { + drawAllDrawings(); + }); + + scheduleRefresh(350); + }; + + const handleUserJoined = (data) => { + try { + if (!data) return; + if (data.roomId !== currentRoomId) return; + console.debug("socket user_joined event", data); + if (data.username) { + showLocalSnack(`${data.username} joined the canvas.`); + } + } catch (e) { } + }; + + const handleUserLeft = (data) => { + try { + if (!data) return; + if (data.roomId !== currentRoomId) return; + console.debug("socket user_left event", data); + if (data.username) { + showLocalSnack(`${data.username} left the canvas.`); + } + } catch (e) { } + }; + + const handleStrokeUndone = (data) => { + console.log("Stroke undone event received:", data); + + forceNextRedrawRef.current = true; + lastDrawnStateRef.current = null; + + // Schedule refresh instead of immediate refresh to avoid flicker + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = setTimeout(() => { + mergedRefreshCanvas("undo-event"); + refreshTimerRef.current = null; + }, 100); + + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + }; + + const handleCanvasCleared = (data) => { + console.log("Canvas cleared event received:", data); + const clearedAt = data && data.clearedAt ? data.clearedAt : Date.now(); + if (currentRoomId) roomClearedAtRef.current[currentRoomId] = clearedAt; + + // Clear local authoritative drawings and pending drawings that predate the clear + try { + userData.clearDrawings(); + } catch (e) { } + setPendingDrawings([]); + serverCountRef.current = 0; + + setUndoStack([]); + setRedoStack([]); + setUndoAvailable(false); + setRedoAvailable(false); + try { + if (currentRoomId) { + roomStacksRef.current[currentRoomId] = { undo: [], redo: [] }; + roomClipboardRef.current[currentRoomId] = null; + } + } catch (e) { } + + clearCanvasForRefresh(); + drawAllDrawings(); + + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + }; + + socket.on("new_stroke", handleNewStroke); + socket.on("stroke_undone", handleStrokeUndone); + socket.on("canvas_cleared", handleCanvasCleared); + socket.on("user_joined", handleUserJoined); + socket.on("user_left", handleUserLeft); + socket.on("user_joined_debug", (d) => { + console.debug("socket user_joined_debug", d); + }); + + return () => { + socket.off("new_stroke", handleNewStroke); + socket.off("stroke_undone", handleStrokeUndone); + socket.off("canvas_cleared", handleCanvasCleared); + socket.off("user_joined", handleUserJoined); + socket.off("user_left", handleUserLeft); + try { + socket.emit("leave_room", { + roomId: currentRoomId, + token: auth?.token, + }); + } catch (e) { + socket.emit("leave_room", { roomId: currentRoomId }); + } + try { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + } catch (e) { } + }; + }, [auth?.token, currentRoomId, auth?.user?.username]); + + useEffect(() => { + (async () => { + try { + setUndoStack([]); + setRedoStack([]); + setUndoAvailable(false); + setRedoAvailable(false); + if (currentRoomId) { + roomStacksRef.current[currentRoomId] = { undo: [], redo: [] }; + } + + // Reset selectedUser tracking when room changes + previousSelectedUserRef.current = null; + isRefreshingSelectedUserRef.current = false; + selectedUserRefreshQueueRef.current = null; + + if (auth?.token && currentRoomId) { + try { + await resetMyStacks(auth.token, currentRoomId); + } catch (e) { } + } + + if (currentRoomId) { + try { + await checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } catch (e) { } + } + } catch (e) { } + })(); + }, [auth?.token, currentRoomId]); + + useEffect(() => { + try { + setUndoStack([]); + setRedoStack([]); + if (currentRoomId) { + roomStacksRef.current[currentRoomId] = { undo: [], redo: [] }; + } + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ).catch(() => { }); + } + } catch (e) { } + }, [auth?.token, currentRoomId]); + + // Force full refresh when selectedUser changes (drawing history selection/deselection) + useEffect(() => { + if (!currentRoomId || !auth?.token) return; + + // Serialize selectedUser for comparison (handles both string and object) + const serializeSelectedUser = (user) => { + if (!user || user === "") return ""; + if (typeof user === "string") return user; + if (typeof user === "object") + return JSON.stringify({ + user: user.user, + periodStart: user.periodStart, + }); + return String(user); + }; + + const currentSerialized = serializeSelectedUser(selectedUser); + const previousSerialized = previousSelectedUserRef.current; + + // Only refresh if selectedUser actually changed + if (currentSerialized === previousSerialized) { + return; + } + + // If a refresh is in progress, queue this change for execution after current one completes + if (isRefreshingSelectedUserRef.current) { + console.debug( + "[selectedUser] Refresh in progress, queuing new selection:", + currentSerialized + ); + selectedUserRefreshQueueRef.current = currentSerialized; + return; + } + + const performRefresh = async (targetSerialized) => { + isRefreshingSelectedUserRef.current = true; + + try { + setIsLoading(true); + + // Update the ref to mark this as the last processed value + previousSelectedUserRef.current = targetSerialized; + + // Force complete refresh from backend + userData.drawings = []; + setPendingDrawings([]); + serverCountRef.current = 0; + lastDrawnStateRef.current = null; + + const isDeselect = !selectedUser || selectedUser === ""; + const logLabel = isDeselect + ? "selectedUser-deselect" + : "selectedUser-select"; + console.debug(`[selectedUser] Performing full refresh: ${logLabel}`, { + to: targetSerialized, + }); + + await clearCanvasForRefresh(); + await mergedRefreshCanvas(logLabel); + await drawAllDrawings(); + } catch (error) { + console.error("Error refreshing on selectedUser change:", error); + } finally { + setIsLoading(false); + isRefreshingSelectedUserRef.current = false; + + // Check if there's a queued refresh waiting + if (selectedUserRefreshQueueRef.current !== null) { + const queuedTarget = selectedUserRefreshQueueRef.current; + selectedUserRefreshQueueRef.current = null; + + // Only process queued refresh if it's different from what we just processed + if (queuedTarget !== targetSerialized) { + console.debug( + "[selectedUser] Processing queued selection:", + queuedTarget + ); + // Use setTimeout to break out of the current call stack + setTimeout(() => performRefresh(queuedTarget), 0); + } + } + } + }; + + // Start the refresh + performRefresh(currentSerialized); + }, [selectedUser, currentRoomId]); + + const clearCanvasForRefresh = async () => { + const canvas = canvasRef.current; + if (!canvas) return; // Guard against null ref during tests + + const context = canvas.getContext("2d"); + if (!context) return; // Guard against null context during tests + + context.clearRect(0, 0, canvasWidth, canvasHeight); + setUserData(initializeUserData()); + setPendingDrawings([]); + serverCountRef.current = 0; + + // Clear selection overlay artifacts + setSelectionRect(null); + setSelectionStart(null); + + // Reset draw mode to freehand if in select mode + if (drawMode === "select") { + setDrawMode("freehand"); + } + }; + + const refreshCanvasButtonHandler = async () => { + if (isRefreshing) return; + setIsRefreshing(true); + setIsLoading(true); + try { + // Force full refresh from backend by clearing local state + userData.drawings = []; + setPendingDrawings([]); + serverCountRef.current = 0; + lastDrawnStateRef.current = null; + + await clearCanvasForRefresh(); + await mergedRefreshCanvas("refresh-button"); + await drawAllDrawings(); + updateFilterState(); // Update filter state after refresh + } catch (error) { + console.error("Error during canvas refresh:", error); + handleAuthError(error); + } finally { + setIsRefreshing(false); + setIsLoading(false); + } + }; + + const initializeUserData = () => { + const uniqueUserId = + auth?.user?.id || + `user_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; + const username = auth?.user?.username || "MainUser"; + return new UserData(uniqueUserId, username); + }; + const [userData, setUserData] = useState(() => initializeUserData()); + const generateId = () => + `drawing_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; + const serverCountRef = useRef(0); + + // Helper function to update filter state + const updateFilterState = () => { + // Use setUserData callback to read current state accurately + setUserData((currentUserData) => { + const filterExists = currentUserData.drawings.some((d) => d.drawingType === "filter"); + const filterCount = currentUserData.drawings.filter((d) => d.drawingType === "filter").length; + console.log(`[updateFilterState] filterExists=${filterExists}, filterCount=${filterCount}`); + setHasFilters(filterExists); + return currentUserData; + }); + }; + + // Advanced Brush/Stamp/Filter Functions + const handleBrushSelect = (brushType) => { + console.log("handleBrushSelect called with:", brushType); + setCurrentBrushType(brushType); + brushEngine.setBrushType(brushType); + setDrawMode("freehand"); + console.log("Current brush type set to:", brushType); + }; + + const handleBrushParamsChange = (params) => { + setBrushParams(params); + brushEngine.setBrushParams(params); + }; + + const placeStamp = async (x, y, stamp, settings) => { + const canvas = canvasRef.current; + if (!canvas || !stamp) return; + + const context = canvas.getContext("2d"); + + // Render stamp immediately using proper context management + if (stamp.emoji) { + context.save(); + context.globalAlpha = settings.opacity / 100; + context.translate(x, y); + context.rotate((settings.rotation * Math.PI) / 180); + + const size = settings.size; + context.font = `${size}px serif`; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(stamp.emoji, 0, 0); + + context.restore(); + } else if (stamp.image) { + // For image stamps, load and render synchronously using async/await + try { + const img = await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = stamp.image; + }); + + context.save(); + context.globalAlpha = settings.opacity / 100; + context.translate(x, y); + context.rotate((settings.rotation * Math.PI) / 180); + + const size = settings.size; + context.drawImage(img, -size / 2, -size / 2, size, size); + + context.restore(); + } catch (error) { + console.error("Failed to load stamp image:", stamp.image?.substring(0, 100), error); + } + } + + // Create drawing record for stamp + const stampDrawing = new Drawing( + generateId(), + color, + lineWidth, + [{ x, y }], + Date.now(), + currentUser, + { + drawingType: "stamp", + stampData: stamp, + stampSettings: settings, + } + ); + + stampDrawing.roomId = currentRoomId; + + userData.addDrawing(stampDrawing); + setPendingDrawings((prev) => [...prev, stampDrawing]); + + // Add to undo stack + setUndoStack((prev) => [...prev, stampDrawing]); + setRedoStack([]); + + // Use submission queue to ensure stamps are submitted in order + try { + const submitTask = async () => { + try { + console.log("Submitting queued stamp:", { + drawingId: stampDrawing.drawingId, + stampData: stampDrawing.stampData, + }); + + await submitToDatabase( + stampDrawing, + auth, + { roomId: currentRoomId, roomType }, + setUndoAvailable, + setRedoAvailable + ); + + console.log("Stamp submitted successfully:", stampDrawing.drawingId); + + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + } catch (error) { + console.error("Error during queued stamp submission:", error); + setPendingDrawings((prev) => + prev.filter((d) => d.drawingId !== stampDrawing.drawingId) + ); + handleAuthError(error); + showLocalSnack("Failed to save stamp. Please try again."); + } + }; + + submissionQueueRef.current.push(submitTask); + processSubmissionQueue(); + } catch (error) { + console.error("Error preparing stamp submission:", error); + handleAuthError(error); + showLocalSnack("Failed to prepare stamp. Please try again."); + } + }; + + const handleStampSelect = (stamp, settings) => { + setSelectedStamp(stamp); + setStampSettings(settings); + setDrawMode("stamp"); + }; + + const handleStampChange = (stamp, settings) => { + setSelectedStamp(stamp); + setStampSettings(settings); + }; + + const applyFilter = async (filterType, params) => { + if (!canvasRef.current) return; + + // Always cancel preview mode first and clean up state + if (isFilterPreview) { + setIsFilterPreview(false); + } + preFilterCanvasStateRef.current = null; + originalCanvasDataRef.current = null; + + // Check if we already have a filter of this type applied + const existingFilterIndex = userData.drawings.findIndex( + (d) => d.drawingType === "filter" && d.filterType === filterType + ); + + let filterDrawing; + let isReplacement = existingFilterIndex !== -1; + + if (isReplacement) { + const existingFilter = userData.drawings[existingFilterIndex]; + existingFilter.filterParams = { ...params }; // Clone params + existingFilter.timestamp = Date.now(); + filterDrawing = existingFilter; + + // Update React state to reflect the filter parameter change + const newUserData = new UserData(userData.userId, userData.username); + newUserData.drawings = [...userData.drawings]; // Clone the array to trigger state update + setUserData(newUserData); + + // Force a complete redraw with the updated filter parameters + // This will redraw all strokes first, then apply the filter + lastDrawnStateRef.current = null; + forceNextRedrawRef.current = true; + await drawAllDrawings(); + + showLocalSnack(`Updated ${filterType} filter`); + updateFilterState(); + + // For filter updates, we need to submit the UPDATE to backend + // The backend should handle this as an update, not a new drawing + try { + await submitToDatabase( + filterDrawing, + auth, + { + roomId: currentRoomId, + roomType, + }, + setUndoAvailable, + setRedoAvailable + ); + + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + } catch (error) { + console.error("Error submitting filter update:", error); + handleAuthError(error); + } + + return; // Exit early for updates + } + + // Create NEW filter record for new filter type + filterDrawing = new Drawing( + generateId(), + "#000000", + 0, + [], + Date.now(), + currentUser, + { + drawingType: "filter", + filterType, + filterParams: { ...params }, // Clone params + } + ); + + // Set filter properties directly on the drawing object + filterDrawing.drawingType = "filter"; + filterDrawing.filterType = filterType; + filterDrawing.filterParams = { ...params }; + filterDrawing.roomId = currentRoomId; + + userData.addDrawing(filterDrawing); + + // Update React state so components know about the new filter + const newUserData = new UserData(userData.userId, userData.username); + newUserData.drawings = [...userData.drawings]; // Clone array with new filter + setUserData(newUserData); + + setPendingDrawings((prev) => [...prev, filterDrawing]); + + setUndoStack((prev) => [...prev, filterDrawing]); + setRedoStack([]); + + // Force complete redraw this will render all strokes THEN apply filter + lastDrawnStateRef.current = null; + forceNextRedrawRef.current = true; + await drawAllDrawings(); + + showLocalSnack(`Applied ${filterType} filter`); + updateFilterState(); + + try { + await submitToDatabase( + filterDrawing, + auth, + { + roomId: currentRoomId, + roomType, + }, + setUndoAvailable, + setRedoAvailable + ); + + // Check undo/redo availability after filter submission + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + } catch (error) { + console.error("Error submitting filter:", error); + // On error, remove the failed filter from pending + setPendingDrawings((prev) => + prev.filter((d) => d.drawingId !== filterDrawing.drawingId) + ); + handleAuthError(error); + } + }; + + const previewFilter = async (filterType, params) => { + const canvas = canvasRef.current; + if (!canvas) return; + + // If already in preview mode, first restore to base state + if (isFilterPreview && preFilterCanvasStateRef.current) { + const img = new Image(); + img.onload = async () => { + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(img, 0, 0); + + // Now apply the new preview + await applyPreviewFilter(canvas, filterType, params); + }; + img.src = preFilterCanvasStateRef.current; + return; + } + + // Store the current canvas state before preview (only once) + if (!preFilterCanvasStateRef.current) { + preFilterCanvasStateRef.current = canvas.toDataURL(); + } + + await applyPreviewFilter(canvas, filterType, params); + }; + + const applyPreviewFilter = async (canvas, filterType, params) => { + // Check if this filter type already exists in the drawings + const existingFilterIndex = userData.drawings.findIndex( + (d) => d.drawingType === "filter" && d.filterType === filterType + ); + + if (existingFilterIndex !== -1) { + // Temporarily remove this filter, redraw, then apply preview + const originalDrawings = [...userData.drawings]; + userData.drawings = userData.drawings.filter((d, i) => i !== existingFilterIndex); + + lastDrawnStateRef.current = null; + forceNextRedrawRef.current = true; + await drawAllDrawings(); + + // Restore drawings array + userData.drawings = originalDrawings; + } + + // Apply the preview filter on top of current canvas + const context = canvas.getContext("2d"); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const filteredImageData = applyImageFilter(imageData, filterType, params); + context.putImageData(filteredImageData, 0, 0); + + setIsFilterPreview(true); + }; + + const undoFilter = async () => { + // If in preview mode, restore from saved canvas state + if (isFilterPreview && preFilterCanvasStateRef.current) { + const canvas = canvasRef.current; + if (!canvas) return; + + const context = canvas.getContext("2d"); + const img = new Image(); + img.onload = async () => { + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(img, 0, 0); + setIsFilterPreview(false); + preFilterCanvasStateRef.current = null; + originalCanvasDataRef.current = null; + }; + img.src = preFilterCanvasStateRef.current; + return; + } + + // If not in preview mode, use regular undo (which properly syncs with backend) + if (!editingEnabled) { + showLocalSnack("Undo is disabled in view-only mode."); + return; + } + + if (undoStack.length === 0) { + showLocalSnack("No actions to undo."); + return; + } + + // Simply call the regular undo function, which will undo the last action + // This properly coordinates with the backend's undo system + await undo(); + }; + + const clearAllFilters = async () => { + // Clear preview state if active + if (isFilterPreview) { + setIsFilterPreview(false); + preFilterCanvasStateRef.current = null; + originalCanvasDataRef.current = null; + } + + if (!editingEnabled) { + showLocalSnack("Clear filters is disabled in view-only mode."); + return; + } + + // Find all filter drawings in userData (not just undo stack) + // Use setUserData callback to get the latest state + let filterDrawings = []; + setUserData((currentUserData) => { + const allDrawings = currentUserData.drawings || []; + filterDrawings = allDrawings.filter( + (drawing) => drawing.drawingType === "filter" + ); + console.log(`[clearAllFilters] Found ${filterDrawings.length} filters to clear`, filterDrawings); + return currentUserData; + }); + + if (filterDrawings.length === 0) { + showLocalSnack("No filters to clear."); + return; + } + + if (isRefreshing) { + showLocalSnack("Please wait for the canvas to refresh."); + return; + } + + try { + showLocalSnack(`Clearing ${filterDrawings.length} filter(s)...`); + + // Get filter IDs before removing from local state + const filterIds = filterDrawings.map(f => f.drawingId).filter(id => id); + + // Remove all filter drawings from local state immediately using proper state update + setUserData((currentUserData) => { + const newUserData = new UserData(currentUserData.userId, currentUserData.username); + newUserData.drawings = currentUserData.drawings.filter( + (d) => d.drawingType !== "filter" + ); + console.log(`[clearAllFilters] Removed ${filterDrawings.length} filters, ${newUserData.drawings.length} drawings remain`); + return newUserData; + }); + + // Remove from pendingDrawings + setPendingDrawings((prev) => + prev.filter((d) => d.drawingType !== "filter") + ); + + // Remove from undo stack (if present) + setUndoStack((prev) => + prev.filter((d) => d.drawingType !== "filter") + ); + + // Force a complete redraw immediately to show filters are gone + lastDrawnStateRef.current = null; + forceNextRedrawRef.current = true; + await drawAllDrawings(); + + showLocalSnack(`Cleared ${filterDrawings.length} filter(s).`); + updateFilterState(); // Update filter state for UI + + // Now sync with backend - create undo markers for each filter + if (filterIds.length > 0) { + try { + // Import the API function + const { markStrokesAsUndone } = await import('../api/rooms'); + + try { + await markStrokesAsUndone(auth.token, currentRoomId, filterIds); + console.log(`Marked ${filterIds.length} filters as undone in backend`); + } catch (apiError) { + // If the API doesn't exist, fall back to calling undo multiple times + console.warn("markStrokesAsUndone API not available, using fallback"); + + // Fallback: call regular undo endpoint for each filter + const { undoRoomAction } = await import('../api/rooms'); + for (let i = 0; i < Math.min(filterDrawings.length, 10); i++) { + try { + const result = await undoRoomAction(auth.token, currentRoomId); + if (result.status === "noop") break; + await new Promise(resolve => setTimeout(resolve, 50)); + } catch (e) { + console.warn("Error calling undoRoomAction:", e); + break; + } + } + } + + await checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } catch (e) { + console.error("Error syncing filter removal with backend:", e); + } + } + } catch (error) { + console.error("Error clearing all filters:", error); + showLocalSnack("Failed to clear all filters. Refreshing canvas..."); + // Refresh to restore state + await refreshCanvasButtonHandler(); + } + }; + + const applyImageFilter = (imageData, filterType, params) => { + const data = imageData.data; + const filtered = new ImageData( + new Uint8ClampedArray(data), + imageData.width, + imageData.height + ); + + switch (filterType) { + case "blur": + return applyBlurFilter(filtered, params.intensity || 5); + case "hueShift": + return applyHueShiftFilter( + filtered, + params.hue || 0, + params.saturation || 0 + ); + case "chalk": + return applyChalkFilter( + filtered, + params.roughness || 50, + params.opacity || 80 + ); + case "fade": + return applyFadeFilter(filtered, params.amount || 30); + case "vintage": + return applyVintageFilter( + filtered, + params.sepia || 60, + params.vignette || 40 + ); + case "neon": + return applyNeonFilter( + filtered, + params.intensity || 15, + params.color || 180 + ); + default: + return filtered; + } + }; + + const applyBlurFilter = (imageData, intensity) => { + // Optimized separable box blur - O(n) instead of O(n²) + // This is much faster and won't crash even with higher intensity values + const data = imageData.data; + const width = imageData.width; + const height = imageData.height; + + const radius = Math.max(1, Math.floor(intensity)); + const temp = new Uint8ClampedArray(data); + const result = new Uint8ClampedArray(data); + + // Horizontal pass + for (let y = 0; y < height; y++) { + let r = 0, g = 0, b = 0, a = 0; + let count = 0; + + // Initialize window + for (let x = -radius; x <= radius; x++) { + if (x >= 0 && x < width) { + const idx = (y * width + x) * 4; + r += data[idx]; + g += data[idx + 1]; + b += data[idx + 2]; + a += data[idx + 3]; + count++; + } + } + + // Slide window across row + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + temp[idx] = r / count; + temp[idx + 1] = g / count; + temp[idx + 2] = b / count; + temp[idx + 3] = a / count; + + // Remove left pixel + const leftX = x - radius; + if (leftX >= 0) { + const leftIdx = (y * width + leftX) * 4; + r -= data[leftIdx]; + g -= data[leftIdx + 1]; + b -= data[leftIdx + 2]; + a -= data[leftIdx + 3]; + count--; + } + + // Add right pixel + const rightX = x + radius + 1; + if (rightX < width) { + const rightIdx = (y * width + rightX) * 4; + r += data[rightIdx]; + g += data[rightIdx + 1]; + b += data[rightIdx + 2]; + a += data[rightIdx + 3]; + count++; + } + } + } + + // Vertical pass + for (let x = 0; x < width; x++) { + let r = 0, g = 0, b = 0, a = 0; + let count = 0; + + // Initialize window + for (let y = -radius; y <= radius; y++) { + if (y >= 0 && y < height) { + const idx = (y * width + x) * 4; + r += temp[idx]; + g += temp[idx + 1]; + b += temp[idx + 2]; + a += temp[idx + 3]; + count++; + } + } + + // Slide window down column + for (let y = 0; y < height; y++) { + const idx = (y * width + x) * 4; + result[idx] = r / count; + result[idx + 1] = g / count; + result[idx + 2] = b / count; + result[idx + 3] = a / count; + + // Remove top pixel + const topY = y - radius; + if (topY >= 0) { + const topIdx = (topY * width + x) * 4; + r -= temp[topIdx]; + g -= temp[topIdx + 1]; + b -= temp[topIdx + 2]; + a -= temp[topIdx + 3]; + count--; + } + + // Add bottom pixel + const bottomY = y + radius + 1; + if (bottomY < height) { + const bottomIdx = (bottomY * width + x) * 4; + r += temp[bottomIdx]; + g += temp[bottomIdx + 1]; + b += temp[bottomIdx + 2]; + a += temp[bottomIdx + 3]; + count++; + } + } + } + + return new ImageData(result, width, height); + }; + + const applyHueShiftFilter = (imageData, hueShift, saturationShift) => { + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Convert RGB to HSL + const max = Math.max(r, g, b) / 255; + const min = Math.min(r, g, b) / 255; + const diff = max - min; + const sum = max + min; + + let h = 0; + const l = sum / 2; + const s = diff === 0 ? 0 : l > 0.5 ? diff / (2 - sum) : diff / sum; + + if (diff !== 0) { + switch (max) { + case r / 255: + h = (g - b) / 255 / diff + (g < b ? 6 : 0); + break; + case g / 255: + h = (b - r) / 255 / diff + 2; + break; + case b / 255: + h = (r - g) / 255 / diff + 4; + break; + } + h /= 6; + } + + // Apply shifts + h = (h + hueShift / 360) % 1; + const newS = Math.max(0, Math.min(1, s + saturationShift / 100)); + + // Convert back to RGB + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + let newR, newG, newB; + + if (newS === 0) { + newR = newG = newB = l; + } else { + const q = l < 0.5 ? l * (1 + newS) : l + newS - l * newS; + const p = 2 * l - q; + newR = hue2rgb(p, q, h + 1 / 3); + newG = hue2rgb(p, q, h); + newB = hue2rgb(p, q, h - 1 / 3); + } + + data[i] = Math.round(newR * 255); + data[i + 1] = Math.round(newG * 255); + data[i + 2] = Math.round(newB * 255); + } + + return imageData; + }; + + const applyChalkFilter = (imageData, roughness, opacity) => { + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const noise = (Math.random() - 0.5) * (roughness / 100) * 255; + const opacityFactor = opacity / 100; + + data[i] = Math.max(0, Math.min(255, data[i] + noise)) * opacityFactor; + data[i + 1] = + Math.max(0, Math.min(255, data[i + 1] + noise)) * opacityFactor; + data[i + 2] = + Math.max(0, Math.min(255, data[i + 2] + noise)) * opacityFactor; + data[i + 3] = data[i + 3] * opacityFactor; + } + + return imageData; + }; + + const applyFadeFilter = (imageData, amount) => { + const data = imageData.data; + const fadeAmount = 1 - amount / 100; + + for (let i = 0; i < data.length; i += 4) { + data[i + 3] = data[i + 3] * fadeAmount; + } + + return imageData; + }; + + const applyVintageFilter = (imageData, sepia, vignette) => { + const data = imageData.data; + const width = imageData.width; + const height = imageData.height; + const sepiaAmount = sepia / 100; + + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Apply sepia + data[i] = Math.min( + 255, + (r * 0.393 + g * 0.769 + b * 0.189) * sepiaAmount + + r * (1 - sepiaAmount) + ); + data[i + 1] = Math.min( + 255, + (r * 0.349 + g * 0.686 + b * 0.168) * sepiaAmount + + g * (1 - sepiaAmount) + ); + data[i + 2] = Math.min( + 255, + (r * 0.272 + g * 0.534 + b * 0.131) * sepiaAmount + + b * (1 - sepiaAmount) + ); + + // Apply vignette + const x = (i / 4) % width; + const y = Math.floor(i / 4 / width); + const centerX = width / 2; + const centerY = height / 2; + const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); + const maxDistance = Math.sqrt(centerX ** 2 + centerY ** 2); + const vignetteAmount = 1 - (distance / maxDistance) * (vignette / 100); + + data[i] *= vignetteAmount; + data[i + 1] *= vignetteAmount; + data[i + 2] *= vignetteAmount; + } + + return imageData; + }; + + const applyNeonFilter = (imageData, intensity, hue) => { + const data = imageData.data; + const width = imageData.width; + const height = imageData.height; + const glowIntensity = intensity / 25; // More aggressive scaling (max 50 -> 2.0) + + // Create a copy for the glow effect + const result = new Uint8ClampedArray(data); + + // Generate neon color based on hue using proper HSL to RGB conversion + const hueNormalized = hue / 360; + const neonR = Math.abs(Math.sin((hueNormalized) * Math.PI * 2)) * 255; + const neonG = Math.abs(Math.sin((hueNormalized + 0.333) * Math.PI * 2)) * 255; + const neonB = Math.abs(Math.sin((hueNormalized + 0.666) * Math.PI * 2)) * 255; + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + + // Only apply effect to visible pixels (any stroke) + if (alpha > 5) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Calculate brightness + const brightness = (r + g + b) / 3; + + // Apply aggressive neon glow with color tinting + const alphaFactor = alpha / 255; + const colorFactor = glowIntensity * alphaFactor; + + // Mix original color with neon color and boost brightness + const boost = 1 + (glowIntensity * 0.8); + result[i] = Math.min(255, (r * boost) + (neonR * colorFactor * 0.7)); + result[i + 1] = Math.min(255, (g * boost) + (neonG * colorFactor * 0.7)); + result[i + 2] = Math.min(255, (b * boost) + (neonB * colorFactor * 0.7)); + + // Ensure the effect is visible even on dark strokes + const minBrightness = 60 * glowIntensity; + const currentBrightness = (result[i] + result[i + 1] + result[i + 2]) / 3; + if (currentBrightness < minBrightness) { + const brightnessFactor = minBrightness / Math.max(currentBrightness, 1); + result[i] = Math.min(255, result[i] * brightnessFactor); + result[i + 1] = Math.min(255, result[i + 1] * brightnessFactor); + result[i + 2] = Math.min(255, result[i + 2] * brightnessFactor); + } + } + } + + return new ImageData(result, width, height); + }; + + const drawAllDrawings = async () => { + const currentTemplateObjects = templateObjectsRef.current || []; + + if (isDrawingInProgressRef.current) { + console.log('Drawing already in progress, skipping drawAllDrawings call'); + return; + } + + isDrawingInProgressRef.current = true; + + // Save current brush state + const savedBrushType = brushEngine ? brushEngine.brushType : null; + const savedBrushParams = brushEngine ? brushEngine.brushParams : null; + + try { + setIsLoading(true); + const canvas = canvasRef.current; + if (!canvas) { + setIsLoading(false); + isDrawingInProgressRef.current = false; + return; + } + const context = canvas.getContext("2d"); + if (!context) { + setIsLoading(false); + isDrawingInProgressRef.current = false; + return; + } + + // Include any locally-pending drawings (e.g. received via socket but + // not yet reflected by a backend refresh) so they render immediately. + const userDrawingIds = new Set((userData.drawings || []).map(d => d.drawingId)); + const uniquePendingDrawings = (pendingDrawings || []).filter( + pd => !userDrawingIds.has(pd.drawingId) + ); + + const combined = [ + ...(userData.drawings || []), + ...uniquePendingDrawings, + ]; + + // Create a state signature to detect if we need to redraw + // Include filter information to ensure redraw when filters change + const filterSignature = combined + .filter(d => d.drawingType === "filter") + .map(f => `${f.drawingId}:${f.filterType}`) + .join(','); + + const stateSignature = JSON.stringify({ + drawingCount: combined.length, + drawingIds: combined.map(d => d.drawingId).sort().join(','), + pendingCount: pendingDrawings.length, + templateCount: currentTemplateObjects?.length || 0, + templateIds: currentTemplateObjects?.map(t => `${t.type}:${t.x || t.x1 || t.cx}:${t.y || t.y1 || t.cy}`).join(',') || '', + filters: filterSignature + }); + + if (lastDrawnStateRef.current === stateSignature) { + console.log('State unchanged, skipping redraw'); + setIsLoading(false); + isDrawingInProgressRef.current = false; + return; + } + + // Clear force flag after checking it + forceNextRedrawRef.current = false; + lastDrawnStateRef.current = stateSignature; + + // for flicker free rendering + if (!offscreenCanvasRef.current || + offscreenCanvasRef.current.width !== canvasWidth || + offscreenCanvasRef.current.height !== canvasHeight + ) { + offscreenCanvasRef.current = document.createElement("canvas"); + offscreenCanvasRef.current.width = canvasWidth; + offscreenCanvasRef.current.height = canvasHeight; + } + + const offscreenContext = offscreenCanvasRef.current.getContext("2d"); + offscreenContext.imageSmoothingEnabled = false; + offscreenContext.clearRect(0, 0, canvasWidth, canvasHeight); + + // This avoids async rendering issues with image stamps + const stampsToRender = []; + + // Create and render template layer separately so it stays below all drawings + let templateCanvas = null; + if (currentTemplateObjects && currentTemplateObjects.length > 0) { + templateCanvas = document.createElement('canvas'); + templateCanvas.width = canvasWidth; + templateCanvas.height = canvasHeight; + const templateContext = templateCanvas.getContext('2d'); + templateContext.imageSmoothingEnabled = false; + + templateContext.save(); + templateContext.globalAlpha = 0.5; + + let renderedCount = 0; + for (const obj of currentTemplateObjects) { + try { + if (obj.type === 'line') { + templateContext.beginPath(); + templateContext.moveTo(obj.x1, obj.y1); + templateContext.lineTo(obj.x2, obj.y2); + templateContext.strokeStyle = obj.color || '#333'; + templateContext.lineWidth = obj.lineWidth || 2; + templateContext.stroke(); + renderedCount++; + } else if (obj.type === 'rectangle') { + templateContext.strokeStyle = obj.stroke || '#333'; + templateContext.lineWidth = obj.lineWidth || 2; + if (obj.fill && obj.fill !== 'transparent') { + templateContext.fillStyle = obj.fill; + templateContext.fillRect(obj.x, obj.y, obj.width, obj.height); + } + templateContext.strokeRect(obj.x, obj.y, obj.width, obj.height); + renderedCount++; + } else if (obj.type === 'circle') { + templateContext.beginPath(); + templateContext.arc(obj.cx, obj.cy, obj.radius, 0, Math.PI * 2); + templateContext.strokeStyle = obj.stroke || '#333'; + templateContext.lineWidth = obj.lineWidth || 2; + if (obj.fill && obj.fill !== 'transparent') { + templateContext.fillStyle = obj.fill; + templateContext.fill(); + } + templateContext.stroke(); + renderedCount++; + } else if (obj.type === 'ellipse') { + templateContext.beginPath(); + templateContext.ellipse(obj.cx, obj.cy, obj.rx, obj.ry, 0, 0, Math.PI * 2); + templateContext.strokeStyle = obj.stroke || '#333'; + templateContext.lineWidth = obj.lineWidth || 2; + if (obj.fill && obj.fill !== 'transparent') { + templateContext.fillStyle = obj.fill; + templateContext.fill(); + } + templateContext.stroke(); + renderedCount++; + } else if (obj.type === 'text') { + templateContext.fillStyle = obj.color || '#333'; + templateContext.font = `${obj.bold ? 'bold ' : ''}${obj.fontSize || 16}px Arial`; + templateContext.fillText(obj.text || '', obj.x, obj.y); + renderedCount++; + } else { + console.warn('Unknown template object type:', obj.type); + } + } catch (e) { + console.warn('Failed to render template object:', obj, e); + } + } + templateContext.restore(); + } else { + console.log('No template objects to render'); + } + + if (templateCanvas) { + offscreenContext.drawImage(templateCanvas, 0, 0); + } + + const cutOriginalIds = new Set(); + try { + combined.forEach((d) => { + if ( + d && + d.pathData && + d.pathData.tool === "cut" && + Array.isArray(d.pathData.originalStrokeIds) + ) { + d.pathData.originalStrokeIds.forEach((id) => + cutOriginalIds.add(id) + ); + } + }); + } catch (e) { } + + const sortedDrawings = combined.sort((a, b) => { + const orderA = + a.order !== undefined ? a.order : a.timestamp || a.ts || 0; + const orderB = + b.order !== undefined ? b.order : b.timestamp || b.ts || 0; + return orderA - orderB; + }); + + // Separate filter drawings from regular drawings + const regularDrawings = []; + const filterDrawings = []; + for (const drawing of sortedDrawings) { + if (drawing.drawingType === "filter") { + filterDrawings.push(drawing); + } else { + regularDrawings.push(drawing); + } + } + + // Pre-load all image stamps to ensure they render in correct z-order + const imageStampCache = new Map(); + const imageStampPromises = []; + + for (const drawing of regularDrawings) { + if (drawing.drawingType === "stamp" && drawing.stampData && drawing.stampData.image && !drawing.stampData.emoji) { + const imageUrl = drawing.stampData.image; + if (!imageStampCache.has(imageUrl)) { + const promise = new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + imageStampCache.set(imageUrl, img); + resolve(); + }; + img.onerror = () => { + console.error("[drawAllDrawings] Failed to pre-load stamp image:", imageUrl.substring(0, 100)); + resolve(); // Continue even if image fails + }; + img.src = imageUrl; + }); + imageStampPromises.push(promise); + } + } + } + + // Wait for all stamp images to load before rendering + if (imageStampPromises.length > 0) { + console.log("[drawAllDrawings] Pre-loading", imageStampPromises.length, "stamp images"); + await Promise.all(imageStampPromises); + console.log("[drawAllDrawings] All stamp images loaded"); + } + + // Render drawings in chronological order. When a 'cut' record appears + // we immediately apply a destination-out erase so it removes prior content + // but does not erase strokes that are drawn after the cut. + const maskedOriginals = new Set(); + let seenAnyCut = false; + + for (const drawing of regularDrawings) { + // If this is a cut record, apply the erase to the canvas now. + if (drawing && drawing.pathData && drawing.pathData.tool === "cut") { + seenAnyCut = true; + try { + if (Array.isArray(drawing.pathData.originalStrokeIds)) { + drawing.pathData.originalStrokeIds.forEach((id) => + maskedOriginals.add(id) + ); + } + } catch (e) { } + + if (drawing.pathData && drawing.pathData.rect) { + const r = drawing.pathData.rect; + offscreenContext.save(); + try { + offscreenContext.globalCompositeOperation = "destination-out"; + offscreenContext.fillStyle = "rgba(0,0,0,1)"; + // Expand rect slightly to avoid hairline due to subpixel antialiasing + offscreenContext.fillRect( + Math.floor(r.x) - 2, + Math.floor(r.y) - 2, + Math.ceil(r.width) + 4, + Math.ceil(r.height) + 4 + ); + } finally { + offscreenContext.restore(); + } + + // Restore template layer in the cut region so templates remain visible + if (templateCanvas) { + offscreenContext.drawImage( + templateCanvas, + Math.floor(r.x) - 2, + Math.floor(r.y) - 2, + Math.ceil(r.width) + 4, + Math.ceil(r.height) + 4, + Math.floor(r.x) - 2, + Math.floor(r.y) - 2, + Math.ceil(r.width) + 4, + Math.ceil(r.height) + 4 + ); + } + } + + continue; + } + + // Skip originals that have been masked by a cut + if ( + drawing && + drawing.drawingId && + (cutOriginalIds.has(drawing.drawingId) || + maskedOriginals.has(drawing.drawingId)) + ) { + continue; + } + + // Skip temporary white "erase" helper strokes when we've seen a cut + // record; destination-out masking is authoritative and drawing white + // strokes can produce hairlines. + try { + if ( + seenAnyCut && + drawing && + drawing.color && + typeof drawing.color === "string" && + drawing.color.toLowerCase() === "#ffffff" + ) { + continue; + } + } catch (e) { } + + // Draw the drawing normally + offscreenContext.globalAlpha = 1.0; + let viewingUser = null; + let viewingPeriodStart = null; + if (selectedUser) { + if (typeof selectedUser === "string") viewingUser = selectedUser; + else if (typeof selectedUser === "object") { + viewingUser = selectedUser.user; + viewingPeriodStart = selectedUser.periodStart; + } + } + if (viewingUser && drawing.user !== viewingUser) { + offscreenContext.globalAlpha = 0.1; + } else if (viewingPeriodStart !== null) { + const ts = drawing.timestamp || drawing.order || 0; + if ( + ts < viewingPeriodStart || + ts >= viewingPeriodStart + 5 * 60 * 1000 + ) { + offscreenContext.globalAlpha = 0.1; + } + } + + // Stamps have pathData as array but need special rendering - render inline to preserve z-order + if (drawing.drawingType === "stamp" && drawing.stampData && drawing.stampSettings && Array.isArray(drawing.pathData) && drawing.pathData.length > 0) { + const stamp = drawing.stampData; + const settings = drawing.stampSettings; + const position = drawing.pathData[0]; + + try { + offscreenContext.save(); + offscreenContext.translate(position.x, position.y); + offscreenContext.rotate(((settings.rotation || 0) * Math.PI) / 180); + + const size = settings.size || 50; + + if (stamp.emoji) { + // Render emoji stamp + offscreenContext.font = `${size}px serif`; + offscreenContext.textAlign = "center"; + offscreenContext.textBaseline = "middle"; + offscreenContext.fillText(stamp.emoji, 0, 0); + console.log("[drawAllDrawings] Rendered emoji stamp inline:", stamp.emoji); + } else if (stamp.image) { + // Render image stamp using pre-loaded image + const img = imageStampCache.get(stamp.image); + if (img) { + offscreenContext.globalAlpha = (settings.opacity || 100) / 100 * offscreenContext.globalAlpha; + offscreenContext.drawImage(img, -size / 2, -size / 2, size, size); + console.log("[drawAllDrawings] Rendered image stamp inline"); + } else { + console.warn("[drawAllDrawings] Image stamp not in cache:", stamp.image?.substring(0, 100)); + } + } + + offscreenContext.restore(); + } catch (error) { + offscreenContext.restore(); + console.error("[drawAllDrawings] Error rendering stamp:", error); + } + } else if (drawing.drawingType === "stamp") { + console.warn("[drawAllDrawings] Stamp NOT rendered - missing requirements:", { + drawingId: drawing.drawingId, + drawingType: drawing.drawingType, + hasStampData: !!drawing.stampData, + hasStampSettings: !!drawing.stampSettings, + pathDataIsArray: Array.isArray(drawing.pathData), + pathDataLength: drawing.pathData ? drawing.pathData.length : 0, + pathDataType: typeof drawing.pathData, + pathDataValue: drawing.pathData, + fullDrawing: drawing + }); + } else if (Array.isArray(drawing.pathData)) { + const pts = drawing.pathData; + if (pts.length > 0) { + // Check if this is an advanced brush drawing + if (drawing.brushType && drawing.brushType !== "normal" && brushEngine) { + console.log("Rendering advanced brush in drawAllDrawings:", { + id: drawing.drawingId, + brushType: drawing.brushType, + pointCount: pts.length + }); + + // Use brush engine to render advanced brush strokes + offscreenContext.save(); + brushEngine.updateContext(offscreenContext); + + // Start the stroke at the first point + offscreenContext.beginPath(); + offscreenContext.moveTo(pts[0].x, pts[0].y); + + // Render the stroke using the brush engine with explicit brush type + brushEngine.startStroke(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) { + // Use drawWithType instead of draw to bypass state dependency + brushEngine.drawWithType( + pts[i].x, + pts[i].y, + drawing.lineWidth, + drawing.color, + drawing.brushType // Pass brush type directly + ); + } + offscreenContext.restore(); + } else { + if (drawing.brushType && drawing.brushType !== "normal") { + console.log("Advanced brush found but no brushEngine:", { + id: drawing.drawingId, + brushType: drawing.brushType, + hasBrushEngine: !!brushEngine + }); + } + // Default rendering for normal brush + offscreenContext.beginPath(); + offscreenContext.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) + offscreenContext.lineTo(pts[i].x, pts[i].y); + offscreenContext.strokeStyle = drawing.color; + offscreenContext.lineWidth = drawing.lineWidth; + offscreenContext.lineCap = drawing.brushStyle || "round"; + offscreenContext.lineJoin = drawing.brushStyle || "round"; + offscreenContext.stroke(); + } + } + } else if (drawing.pathData && drawing.pathData.tool === "shape") { + if (drawing.pathData.points) { + const pts = drawing.pathData.points; + offscreenContext.save(); + offscreenContext.beginPath(); + offscreenContext.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) + offscreenContext.lineTo(pts[i].x, pts[i].y); + offscreenContext.closePath(); + offscreenContext.fillStyle = drawing.color; + offscreenContext.fill(); + offscreenContext.restore(); + } else { + const { + type, + start, + end, + brushStyle: storedBrush, + } = drawing.pathData; + offscreenContext.save(); + offscreenContext.fillStyle = drawing.color; + offscreenContext.lineWidth = drawing.lineWidth; + if (type === "circle") { + const radius = Math.sqrt( + (end.x - start.x) ** 2 + (end.y - start.y) ** 2 + ); + offscreenContext.beginPath(); + offscreenContext.arc(start.x, start.y, radius, 0, Math.PI * 2); + offscreenContext.fill(); + } else if (type === "rectangle") { + offscreenContext.fillRect( + start.x, + start.y, + end.x - start.x, + end.y - start.y + ); + } else if (type === "hexagon") { + const radius = Math.sqrt( + (end.x - start.x) ** 2 + (end.y - start.y) ** 2 + ); + offscreenContext.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i; + const xPoint = start.x + radius * Math.cos(angle); + const yPoint = start.y + radius * Math.sin(angle); + if (i === 0) offscreenContext.moveTo(xPoint, yPoint); + else offscreenContext.lineTo(xPoint, yPoint); + } + offscreenContext.closePath(); + offscreenContext.fill(); + } else if (type === "line") { + offscreenContext.beginPath(); + offscreenContext.moveTo(start.x, start.y); + offscreenContext.lineTo(end.x, end.y); + offscreenContext.strokeStyle = drawing.color; + offscreenContext.lineWidth = drawing.lineWidth; + const cap = storedBrush || drawing.brushStyle || "round"; + offscreenContext.lineCap = cap; + offscreenContext.lineJoin = cap; + offscreenContext.stroke(); + } + offscreenContext.restore(); + } + } else if (drawing.pathData && drawing.pathData.tool === "image") { + const { image, x, y, width, height } = drawing.pathData; + let img = new Image(); + img.src = image; + img.onload = () => { + offscreenContext.drawImage(img, x, y, width, height); + }; + } + } + if (!selectedUser) { + // Group users by 5-minute intervals + // Use both committed drawings and pending drawings so the UI's + // user/time-group list reflects the strokes the user currently sees. + const groupMap = {}; + const groupingSource = [ + ...(userData.drawings || []), + ...(pendingDrawings || []), + ]; + groupingSource.forEach((d) => { + try { + const ts = d.timestamp || d.order || 0; + const periodStart = + Math.floor(ts / (5 * 60 * 1000)) * (5 * 60 * 1000); + if (!groupMap[periodStart]) groupMap[periodStart] = new Set(); + if (d.user) groupMap[periodStart].add(d.user); + } catch (e) { } + }); + const groups = Object.keys(groupMap).map((k) => ({ + periodStart: parseInt(k), + users: Array.from(groupMap[k]), + })); + groups.sort((a, b) => b.periodStart - a.periodStart); + if (selectedUser && selectedUser !== "") { + let stillExists = false; + if (typeof selectedUser === "string") { + for (const g of groups) { + if (g.users.includes(selectedUser)) { + stillExists = true; + break; + } + } + } else if (typeof selectedUser === "object" && selectedUser.user) { + for (const g of groups) { + if ( + g.periodStart === selectedUser.periodStart && + g.users.includes(selectedUser.user) + ) { + stillExists = true; + break; + } + } + } + + if (!stillExists) { + try { + setSelectedUser(""); + } catch (e) { + /* swallow if setter changed */ + } + } + } + + setUserList(groups); + } + + // Apply filters as post-processing after all regular drawings are rendered + if (filterDrawings.length > 0) { + console.log("[drawAllDrawings] Applying", filterDrawings.length, "filter(s)"); + for (const filterDrawing of filterDrawings) { + try { + if (filterDrawing.filterType && filterDrawing.filterParams) { + const imageData = offscreenContext.getImageData(0, 0, canvasWidth, canvasHeight); + const filteredImageData = applyImageFilter( + imageData, + filterDrawing.filterType, + filterDrawing.filterParams + ); + offscreenContext.putImageData(filteredImageData, 0, 0); + console.log("[drawAllDrawings] Applied filter:", filterDrawing.filterType); + } + } catch (e) { + console.error("[drawAllDrawings] Error applying filter:", filterDrawing.filterType, e); + } + } + } + + // Copy offscreen canvas to visible canvas atomically + console.log("[drawAllDrawings] Copying offscreen canvas to visible canvas. Total strokes rendered:", regularDrawings.length, "filters:", filterDrawings.length); + context.imageSmoothingEnabled = false; + context.clearRect(0, 0, canvasWidth, canvasHeight); + context.drawImage(offscreenCanvasRef.current, 0, 0); + console.log("[drawAllDrawings] Canvas update complete"); + } catch (e) { + console.error("Error in drawAllDrawings:", e); + } finally { + // Restore current brush state + if (brushEngine && savedBrushType) { + brushEngine.setBrushType(savedBrushType); + if (savedBrushParams) { + brushEngine.setBrushParams(savedBrushParams); + } + } + setIsLoading(false); + isDrawingInProgressRef.current = false; + } + }; + + drawAllDrawingsRef.current = drawAllDrawings; + + const undo = async () => { + if (!editingEnabled) { + showLocalSnack("Undo is disabled in view-only mode."); + return; + } + if (undoStack.length === 0) return; + if (isRefreshing) { + showLocalSnack( + "Please wait for the canvas to refresh before undoing again." + ); + return; + } + try { + await undoAction({ + auth, + currentUser: auth?.username || "anonymous", + undoStack, + setUndoStack, + setRedoStack, + userData, + drawAllDrawings, + refreshCanvasButtonHandler: refreshCanvasButtonHandler, + roomId: currentRoomId, + }); + // After undo completes, refresh undo/redo availability from server + try { + await checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } catch (e) { } + updateFilterState(); // Update filter state after undo + } catch (error) { + console.error("Error during undo:", error); + } + }; + + const redo = async () => { + if (!editingEnabled) { + showLocalSnack("Redo is disabled in view-only mode."); + return; + } + if (redoStack.length === 0) return; + if (isRefreshing) { + showLocalSnack( + "Please wait for the canvas to refresh before redoing again." + ); + return; + } + try { + await redoAction({ + auth, + currentUser: auth?.username || "anonymous", + redoStack, + setRedoStack, + setUndoStack, + userData, + drawAllDrawings, + refreshCanvasButtonHandler: refreshCanvasButtonHandler, + roomId: currentRoomId, + }); + // After redo completes, refresh undo/redo availability from server + try { + await checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } catch (e) { } + updateFilterState(); // Update filter state after redo + } catch (error) { + console.error("Error during redo:", error); + } + }; + + // Register keyboard shortcuts and commands + useEffect(() => { + // Initialize shortcut manager + if (!shortcutManagerRef.current) { + shortcutManagerRef.current = new KeyboardShortcutManager(); + } + + const manager = shortcutManagerRef.current; + + // Register all commands with the command registry + const commands = [ + // Command Palette & Help + { + id: 'commands.palette', + label: 'Open Command Palette', + description: 'Quick access to all commands', + keywords: ['palette', 'search', 'find'], + category: 'Commands', + action: () => setCommandPaletteOpen(true), + shortcut: { key: 'k', modifiers: { ctrl: true } } + }, + { + id: 'commands.shortcuts', + label: 'Show Keyboard Shortcuts', + description: 'View all available keyboard shortcuts', + keywords: ['help', 'shortcuts', 'keys'], + category: 'Commands', + action: () => setShortcutsHelpOpen(true), + shortcut: { key: '/', modifiers: { ctrl: true } } + }, + { + id: 'commands.cancel', + label: 'Cancel / Escape', + description: 'Cancel current action or close dialogs', + keywords: ['cancel', 'escape', 'close'], + category: 'Commands', + action: () => { + if (commandPaletteOpen) setCommandPaletteOpen(false); + else if (shortcutsHelpOpen) setShortcutsHelpOpen(false); + else if (drawing) setDrawing(false); + }, + shortcut: { key: 'Escape', modifiers: {} } + }, + + // Edit Operations + { + id: 'edit.undo', + label: 'Undo', + description: 'Undo the last action', + keywords: ['undo', 'revert'], + category: 'Edit', + action: undo, + shortcut: { key: 'z', modifiers: { ctrl: true } }, + enabled: () => editingEnabled && undoStack.length > 0 + }, + { + id: 'edit.redo', + label: 'Redo', + description: 'Redo the last undone action', + keywords: ['redo', 'repeat'], + category: 'Edit', + action: redo, + shortcut: { key: 'z', modifiers: { ctrl: true, shift: true } }, + enabled: () => editingEnabled && redoStack.length > 0 + }, + + // Canvas Operations + { + id: 'canvas.clear', + label: 'Clear Canvas', + description: 'Remove all strokes from canvas', + keywords: ['clear', 'delete', 'reset'], + category: 'Canvas', + action: () => { + if (editingEnabled) { + setClearDialogOpen(true); + } else { + showLocalSnack('Canvas clearing is disabled in view-only mode'); + } + }, + shortcut: { key: 'k', modifiers: { ctrl: true, shift: true } }, + enabled: () => editingEnabled + }, + { + id: 'canvas.refresh', + label: 'Refresh Canvas', + description: 'Reload canvas from server', + keywords: ['refresh', 'reload'], + category: 'Canvas', + action: refreshCanvasButtonHandler, + shortcut: { key: 'r', modifiers: { ctrl: true } } + }, + { + id: 'canvas.settings', + label: 'Canvas Settings', + description: 'Open canvas settings', + keywords: ['settings', 'preferences'], + category: 'Canvas', + action: () => { + if (onOpenSettings) onOpenSettings(); + }, + shortcut: { key: ',', modifiers: { ctrl: true } }, + visible: () => !!onOpenSettings + }, + + // Tools + { + id: 'tool.pen', + label: 'Select Pen Tool', + description: 'Switch to freehand drawing', + keywords: ['pen', 'draw', 'brush'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('freehand'); + showLocalSnack('Pen tool selected'); + } + }, + shortcut: { key: 'p', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.eraser', + label: 'Select Eraser', + description: 'Switch to eraser mode', + keywords: ['eraser', 'erase', 'remove'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('eraser'); + showLocalSnack('Eraser selected'); + } + }, + shortcut: { key: 'e', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.rectangle', + label: 'Select Rectangle Tool', + description: 'Draw rectangles and squares', + keywords: ['rectangle', 'rect', 'square'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('shape'); + setShapeType('rectangle'); + showLocalSnack('Rectangle tool selected'); + } + }, + shortcut: { key: 'r', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.circle', + label: 'Select Circle Tool', + description: 'Draw circles and ellipses', + keywords: ['circle', 'oval', 'ellipse'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('shape'); + setShapeType('circle'); + showLocalSnack('Circle tool selected'); + } + }, + shortcut: { key: 'c', modifiers: {} }, + enabled: () => editingEnabled + }, + { + id: 'tool.line', + label: 'Select Line Tool', + description: 'Draw straight lines', + keywords: ['line', 'straight'], + category: 'Tools', + action: () => { + if (editingEnabled) { + setDrawMode('shape'); + setShapeType('line'); + showLocalSnack('Line tool selected'); + } + }, + shortcut: { key: 'l', modifiers: {} }, + enabled: () => editingEnabled + } + ]; + + // Register commands with command registry + // Clear first to ensure clean state + commandRegistry.clear(); + + // Register each command (allowOverwrite for React re-renders) + commands.forEach(cmd => { + commandRegistry.register(cmd, { allowOverwrite: true }); + }); + + // Register keyboard shortcuts + manager.clear(); + commands.forEach(cmd => { + if (cmd.shortcut) { + manager.register( + cmd.shortcut.key, + cmd.shortcut.modifiers, + () => { + // Check if command is enabled before executing + if (cmd.enabled && !cmd.enabled()) { + return; + } + cmd.action(); + }, + cmd.label, + cmd.category + ); + } + }); + + // Add global keyboard event listener + const handleKeyDown = (event) => manager.handleKeyDown(event); + document.addEventListener('keydown', handleKeyDown); + + // Cleanup + return () => { + document.removeEventListener('keydown', handleKeyDown); + manager.clear(); + }; + }, [ + editingEnabled, + undoStack, + redoStack, + undo, + redo, + refreshCanvasButtonHandler, + onOpenSettings, + commandPaletteOpen, + shortcutsHelpOpen, + drawing + ]); + + const { + selectionStart, + setSelectionStart, + selectionRect, + setSelectionRect, + cutImageData, + setCutImageData, + handleCutSelection, + } = useCanvasSelection( + canvasRef, + currentUser, + userData, + generateId, + drawAllDrawings, + currentRoomId, + setUndoAvailable, + setRedoAvailable, + auth, + roomType, + showLocalSnack + ); + + // AI Assist functions + const { beautifySketch, aiAssistLoading } = useAIAssistant(); + + // Draw a preview of a shape (for shape mode) + const drawShapePreview = (start, end, shape, color, lineWidth) => { + if (!start || !end) return; + + const canvas = canvasRef.current; + const context = canvas.getContext("2d"); + context.save(); + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.setLineDash([5, 3]); + + if (shape === "circle") { + const radius = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2); + context.beginPath(); + context.arc(start.x, start.y, radius, 0, Math.PI * 2); + context.stroke(); + } else if (shape === "rectangle") { + context.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y); + } else if (shape === "hexagon") { + const radius = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2); + context.beginPath(); + + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i; + const xPoint = start.x + radius * Math.cos(angle); + const yPoint = start.y + radius * Math.sin(angle); + + if (i === 0) context.moveTo(xPoint, yPoint); + else context.lineTo(xPoint, yPoint); + } + context.closePath(); + context.stroke(); + } else if (shape === "line") { + context.beginPath(); + context.moveTo(start.x, start.y); + context.lineTo(end.x, end.y); + context.lineCap = brushStyle; + context.lineJoin = brushStyle; + context.stroke(); + } + + context.restore(); + }; + + // Handle paste action for cut selection + const handlePaste = async (e) => { + if (!editingEnabled) { + showLocalSnack("Editing is disabled in view-only mode."); + setDrawMode("freehand"); + return; + } + if ( + !cutImageData || + !Array.isArray(cutImageData) || + cutImageData.length === 0 + ) { + showLocalSnack("No cut selection available to paste."); + setDrawMode("freehand"); + return; + } + + const canvas = canvasRef.current; + const rectCanvas = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rectCanvas.width; + const scaleY = canvas.height / rectCanvas.height; + const pasteX = (e.clientX - rectCanvas.left) * scaleX; + const pasteY = (e.clientY - rectCanvas.top) * scaleY; + + let minX = Infinity, + minY = Infinity; + + cutImageData.forEach((drawing) => { + if (Array.isArray(drawing.pathData)) { + drawing.pathData.forEach((pt) => { + minX = Math.min(minX, pt.x); + minY = Math.min(minY, pt.y); + }); + } else if (drawing.pathData && drawing.pathData.tool === "shape") { + if (drawing.pathData.points && Array.isArray(drawing.pathData.points)) { + drawing.pathData.points.forEach((pt) => { + minX = Math.min(minX, pt.x); + minY = Math.min(minY, pt.y); + }); + } else if (drawing.pathData.type === "line") { + if (drawing.pathData.start) { + minX = Math.min(minX, drawing.pathData.start.x); + minY = Math.min(minY, drawing.pathData.start.y); + } + if (drawing.pathData.end) { + minX = Math.min(minX, drawing.pathData.end.x); + minY = Math.min(minY, drawing.pathData.end.y); + } + } + } + }); + + if (minX === Infinity || minY === Infinity) { + showLocalSnack("Invalid cut data."); + return; + } + + const offsetX = pasteX - minX; + const offsetY = pasteY - minY; + let pastedDrawings = []; + + const newDrawings = cutImageData + .map((originalDrawing) => { + let newPathData; + if (Array.isArray(originalDrawing.pathData)) { + newPathData = originalDrawing.pathData.map((pt) => ({ + x: pt.x + offsetX, + y: pt.y + offsetY, + })); + } else if ( + originalDrawing.pathData && + originalDrawing.pathData.tool === "shape" + ) { + if ( + originalDrawing.pathData.points && + Array.isArray(originalDrawing.pathData.points) + ) { + const newPoints = originalDrawing.pathData.points.map((pt) => ({ + x: pt.x + offsetX, + y: pt.y + offsetY, + })); + newPathData = { ...originalDrawing.pathData, points: newPoints }; + } else if (originalDrawing.pathData.type === "line") { + const newStart = { + x: originalDrawing.pathData.start.x + offsetX, + y: originalDrawing.pathData.start.y + offsetY, + }; + const newEnd = { + x: originalDrawing.pathData.end.x + offsetX, + y: originalDrawing.pathData.end.y + offsetY, + }; + newPathData = { + ...originalDrawing.pathData, + start: newStart, + end: newEnd, + }; + } + } else { + return null; + } + + // Preserve all metadata from original drawing + const metadata = { + brushStyle: originalDrawing.brushStyle, + brushType: originalDrawing.brushType, + brushParams: originalDrawing.brushParams, + drawingType: originalDrawing.drawingType, + stampData: originalDrawing.stampData, + stampSettings: originalDrawing.stampSettings, + filterType: originalDrawing.filterType, + filterParams: originalDrawing.filterParams, + }; + + return new Drawing( + generateId(), + originalDrawing.color, + originalDrawing.lineWidth, + newPathData, + Date.now(), + currentUser, + metadata + ); + }) + .filter(Boolean); + + setIsRefreshing(true); + setRedoStack([]); + + const pasteRecordId = generateId(); + showLocalSnack(`Pasting ${newDrawings.length} item(s)... Please wait.`); + console.log("[handlePaste] Starting paste operation:", { + pasteRecordId, + drawingCount: newDrawings.length, + drawingTypes: newDrawings.map(d => d.drawingType || "stroke") + }); + + // Attach parentPasteId to each new drawing so the backend/read path can filter them + for (const nd of newDrawings) { + nd.roomId = currentRoomId; + nd.parentPasteId = pasteRecordId; + if (!nd.pathData) nd.pathData = {}; + nd.pathData.parentPasteId = pasteRecordId; + } + console.log("[handlePaste] Attached parentPasteId to all drawings:", pasteRecordId); + + // Submit all pasted drawings as replacement/child strokes but DO NOT add each to the undo stack + let submittedCount = 0; + for (const newDrawing of newDrawings) { + try { + userData.addDrawing(newDrawing); + + await submitToDatabase( + newDrawing, + auth, + { roomId: currentRoomId, roomType, skipUndoStack: true }, + setUndoAvailable, + setRedoAvailable + ); + pastedDrawings.push(newDrawing); + submittedCount++; + + showLocalSnack(`Pasting... ${submittedCount}/${newDrawings.length} items saved.`); + } catch (error) { + console.error("Failed to save drawing:", newDrawing, error); + handleAuthError(error); + } + } + + const pastedIds = pastedDrawings.map((d) => d.drawingId); + const pasteRecord = new Drawing( + pasteRecordId, + "#FFFFFF", + 1, + { tool: "paste", cut: false, pastedDrawingIds: pastedIds }, + Date.now(), + currentUser + ); + console.log("[handlePaste] Created paste record:", { + pasteRecordId, + pastedCount: pastedIds.length, + pastedIds: pastedIds.join(',') + }); + try { + // Submit the single paste-record (counts as one backend undo operation) + await submitToDatabase( + pasteRecord, + auth, + { roomId: currentRoomId, roomType }, + setUndoAvailable, + setRedoAvailable + ); + console.log("[handlePaste] Paste record submitted successfully"); + setUndoStack((prev) => [ + ...prev, + { type: "paste", pastedDrawings: pastedDrawings, backendCount: 1 }, + ]); + } catch (error) { + console.error("Failed to save paste record:", pasteRecord, error); + showLocalSnack("Paste failed to persist. Some strokes may be missing."); + } + + setIsRefreshing(false); + + // Update undo/redo availability after paste operations + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + + tempPathRef.current = []; + if (pastedDrawings.length === newDrawings.length) { + drawAllDrawings(); + setCutImageData([]); + setDrawMode("freehand"); + showLocalSnack(`Paste completed! ${pastedDrawings.length} item(s) pasted successfully.`); + } else { + showLocalSnack(`Paste partially completed. ${pastedDrawings.length}/${newDrawings.length} items pasted.`); + } + }; + + const mergedRefreshCanvas = async (sourceLabel = undefined) => { + try { + if (sourceLabel) { + console.log('mergedRefreshCanvas called from:', sourceLabel, '==='); + console.debug('mergedRefreshCanvas called from:', sourceLabel); + } else { + console.log('mergedRefreshCanvas called (no label) ==='); + console.debug('mergedRefreshCanvas called'); + } + } catch (e) { } + // If currently panning, defer refresh until pan ends to avoid races and frequent backend calls. + try { + if (isPanning) { + console.debug( + "[mergedRefreshCanvas] deferring because isPanning=true, marking pendingPanRefreshRef" + ); + pendingPanRefreshRef.current = true; + return; + } + } catch (e) { } + + if (sourceLabel === "undo-event" || sourceLabel === "redo-event") { + console.log("[mergedRefreshCanvas] Forcing complete state reset for undo/redo"); + lastDrawnStateRef.current = null; + } + + setIsLoading(true); + const backendCount = await backendRefreshCanvas( + serverCountRef.current, + userData, + drawAllDrawings, + historyRange ? historyRange.start : undefined, + historyRange ? historyRange.end : undefined, + { + roomId: currentRoomId, + auth, + clearLastDrawnState: () => { + console.log("[mergedRefreshCanvas] Clearing lastDrawnStateRef to force redraw"); + lastDrawnStateRef.current = null; + } + } + ); + + const pendingSnapshot = [...pendingDrawings]; + + // Don't clear all pending drawings, only mark confirmed ones for removal + + serverCountRef.current = backendCount; + // Re-append any pending drawings that the backend didn't return. + const drawingMatches = (a, b) => { + if (!a || !b) return false; + if (a.drawingId && b.drawingId && a.drawingId === b.drawingId) + return true; + + try { + const sameUser = a.user === b.user; + const tsA = a.timestamp || a.ts || 0; + const tsB = b.timestamp || b.ts || 0; + const tsClose = Math.abs(tsA - tsB) < 1000; + const lenA = Array.isArray(a.pathData) + ? a.pathData.length + : a.pathData && a.pathData.points + ? a.pathData.points.length + : 0; + const lenB = Array.isArray(b.pathData) + ? b.pathData.length + : b.pathData && b.pathData.points + ? b.pathData.points.length + : 0; + const lenClose = Math.abs(lenA - lenB) <= 1; + return sameUser && tsClose && lenClose; + } catch (e) { + return false; + } + }; + + try { + const cutOriginalIds = new Set(); + (userData.drawings || []).forEach((d) => { + if ( + d.pathData && + d.pathData.tool === "cut" && + Array.isArray(d.pathData.originalStrokeIds) + ) { + d.pathData.originalStrokeIds.forEach((id) => cutOriginalIds.add(id)); + } + }); + + if (cutOriginalIds.size > 0) { + userData.drawings = (userData.drawings || []).filter( + (d) => !cutOriginalIds.has(d.drawingId) + ); + } + } catch (e) { + // best-effort + } + + // Re-append pending drawings that the backend didn't return, but + // skip any pending items older than the authoritative clearedAt timestamp + const clearedAt = currentRoomId + ? roomClearedAtRef.current[currentRoomId] + : null; + const stillPending = []; + + pendingSnapshot.forEach((pd) => { + try { + const pdTs = pd.timestamp || pd.ts || 0; + if (clearedAt && pdTs < clearedAt) { + // This pending drawing was created before a server clear; ignore it + return; + } + } catch (e) { } + + const exists = userData.drawings.find((d) => drawingMatches(d, pd)); + if (!exists) { + // Backend doesn't have it yet, keep it pending + userData.drawings.push(pd); + stillPending.push(pd); + } else { + // If pending drawing has stampData but backend version doesn't, use pending version + if (pd.drawingType === "stamp" && pd.stampData) { + const backendMatch = exists; + if (!backendMatch.stampData || !backendMatch.stampData.image && pd.stampData.image) { + console.warn("Backend stamp missing stampData, using pending version:", { + drawingId: pd.drawingId, + pendingHasStampData: !!pd.stampData, + backendHasStampData: !!backendMatch.stampData, + pendingImageLength: pd.stampData.image ? pd.stampData.image.length : 0, + backendImageLength: backendMatch.stampData && backendMatch.stampData.image ? backendMatch.stampData.image.length : 0 + }); + + // Replace backend version with pending version that has complete data + const idx = userData.drawings.findIndex((d) => drawingMatches(d, pd)); + if (idx !== -1) { + userData.drawings[idx] = pd; + } + } + } + + // Backend has it, mark as confirmed and remove from pending + if (pd.drawingId) { + confirmedStrokesRef.current.add(pd.drawingId); + } + } + }); + + // Update pending drawings to only include those still not confirmed by backend + setPendingDrawings(stillPending); + + // CRITICAL: Deduplicate filters - only keep the LATEST of each filter type + // This prevents stacking when backend returns duplicates + const filtersByType = new Map(); + const nonFilterDrawings = []; + + (userData.drawings || []).forEach((drawing) => { + if (drawing.drawingType === "filter" && drawing.filterType) { + const existing = filtersByType.get(drawing.filterType); + // Keep the one with the latest timestamp + if (!existing || (drawing.timestamp || 0) > (existing.timestamp || 0)) { + filtersByType.set(drawing.filterType, drawing); + } + } else { + nonFilterDrawings.push(drawing); + } + }); + + // Rebuild drawings array with deduplicated filters + const deduplicatedDrawings = [ + ...nonFilterDrawings, + ...Array.from(filtersByType.values()) + ]; + + console.log(`[mergedRefreshCanvas] Deduplicated filters. Filter count: ${filtersByType.size}, Total drawings: ${deduplicatedDrawings.length}`); + + // CRITICAL: Update both the mutable userData object AND React state + // Update userData in place so the closure reference works + userData.drawings = deduplicatedDrawings; + + // Also update React state to trigger re-renders + const newUserData = new UserData(userData.userId, userData.username); + newUserData.drawings = deduplicatedDrawings; + setUserData(newUserData); + + // Extract custom stamps from all drawings and update stamp panel + extractCustomStamps(); + + // Use requestAnimationFrame for smoother rendering + requestAnimationFrame(() => { + drawAllDrawings(); + setIsLoading(false); + updateFilterState(); // Update filter state after loading drawings + }); + }; + + // Extract custom stamps from backend drawings and update StampPanel + const extractCustomStamps = () => { + try { + const customStamps = []; + const seenStamps = new Map(); // Deduplicate by image content or emoji + + (userData.drawings || []).forEach((drawing) => { + if (drawing.drawingType === "stamp" && drawing.stampData) { + const stamp = drawing.stampData; + + // Skip default emoji stamps (they're already in StampPanel) + if (stamp.emoji && !stamp.image) { + return; + } + + // For custom image stamps, create a unique key based on image content + if (stamp.image) { + const imageKey = stamp.image.substring(0, 100); // Use first 100 chars as key + + if (!seenStamps.has(imageKey)) { + seenStamps.set(imageKey, true); + customStamps.push({ + id: `stamp-${Date.now()}-${customStamps.length}`, + name: stamp.name || 'Custom Stamp', + category: stamp.category || 'custom', + image: stamp.image, + emoji: stamp.emoji + }); + } + } + } + }); + + if (customStamps.length > 0) { + console.log('Extracted custom stamps from backend:', customStamps.length); + setBackendStamps(customStamps); + } + } catch (error) { + console.error('Error extracting custom stamps:', error); + } + }; + + const startDrawingHandler = (e) => { + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (e.button === 1) { + // Middle mouse button: start panning + setIsPanning(true); + panStartRef.current = { x: e.clientX, y: e.clientY }; + panOriginRef.current = { ...panOffset }; + setIsLoading(true); + // Throttle pan-triggered refreshes: if we recently refreshed, defer until pan end + try { + const now = Date.now(); + const diff = now - panLastRefreshRef.current; + console.debug( + `[pan] now=${now} lastRefresh=${panLastRefreshRef.current} diff=${diff} cooldown=${PAN_REFRESH_COOLDOWN_MS}` + ); + if (diff > PAN_REFRESH_COOLDOWN_MS) { + panLastRefreshRef.current = now; + console.debug("[pan] triggering immediate mergedRefreshCanvas"); + mergedRefreshCanvas("pan-start").finally(() => setIsLoading(false)); + } else { + // Mark that we skipped the immediate refresh and schedule a deferred refresh on mouseup + panRefreshSkippedRef.current = true; + console.debug( + "[pan] skipped immediate refresh; scheduling deferred refresh on mouseup" + ); + if (panEndRefreshTimerRef.current) + clearTimeout(panEndRefreshTimerRef.current); + panEndRefreshTimerRef.current = setTimeout(() => { + if (panRefreshSkippedRef.current) { + panRefreshSkippedRef.current = false; + panLastRefreshRef.current = Date.now(); + console.debug("[pan] deferred timer firing mergedRefreshCanvas"); + mergedRefreshCanvas("pan-deferred").finally(() => + setIsLoading(false) + ); + } + panEndRefreshTimerRef.current = null; + }, Math.max(200, PAN_REFRESH_COOLDOWN_MS - diff)); + setIsLoading(false); + } + } catch (e) { + mergedRefreshCanvas().finally(() => setIsLoading(false)); + } + return; + } + + if (!editingEnabled) return; + + if (drawMode === "eraser" || drawMode === "freehand") { + const context = canvas.getContext("2d"); + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.lineCap = brushStyle; + context.lineJoin = brushStyle; + + // Initialize brush engine for advanced brushes + if (brushEngine) { + brushEngine.updateContext(context); + brushEngine.startStroke(x, y); + + // For normal brush, we still need the standard path setup + if (currentBrushType === "normal") { + context.beginPath(); + context.moveTo(x, y); + } + } else { + // Fallback if no brush engine + context.beginPath(); + context.moveTo(x, y); + } + + tempPathRef.current = [{ x, y }]; + setDrawing(true); + } else if (drawMode === "shape") { + setShapeStart({ x, y }); + setDrawing(true); + + const dataURL = canvas.toDataURL(); + let snapshotImg = new Image(); + + snapshotImg.src = dataURL; + snapshotRef.current = snapshotImg; + } else if (drawMode === "select") { + setSelectionStart({ x, y }); + setSelectionRect(null); + setDrawing(true); + + const dataURL = canvas.toDataURL(); + let snapshotImg = new Image(); + + snapshotImg.src = dataURL; + snapshotRef.current = snapshotImg; + } else if (drawMode === "paste") { + handlePaste(e); + } else if (drawMode === "stamp") { + // Start stamp preview on mousedown (will place on mouseup) + if (selectedStamp && stampSettings) { + setStampPreview({ x, y, stamp: selectedStamp, settings: stampSettings }); + stampPreviewRef.current = { x, y, stamp: selectedStamp, settings: stampSettings }; + setDrawing(true); // Enable dragging + } + } + }; + + const handlePan = (e) => { + if (!isPanning) return; + + // If the middle button is no longer pressed, stop panning. + if (!(e.buttons & 4)) { + setIsPanning(false); + panOriginRef.current = { ...panOffset }; + return; + } + + const deltaX = e.clientX - panStartRef.current.x; + const deltaY = e.clientY - panStartRef.current.y; + let newX = panOriginRef.current.x + deltaX; + let newY = panOriginRef.current.y + deltaY; + const containerWidth = window.innerWidth; + const containerHeight = window.innerHeight; + + // Calculate minimum allowed offsets so that the canvas edge is not exceeded. + // Our canvas is fixed at canvasWidth and canvasHeight. + const minX = containerWidth - canvasWidth; // This will be negative if canvasWidth > containerWidth + const minY = containerHeight - canvasHeight; + + newX = clamp(newX, minX, 0); + newY = clamp(newY, minY, 0); + + setPanOffset({ + x: newX, + y: newY, + }); + }; + + const drawHandler = (e) => { + if (isPanning) { + handlePan(e); + return; + } + if (!editingEnabled) return; // prevent drawing but allow other handlers like panning to proceed + if (!drawing) return; + + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + console.log( + "Drawing with brush type:", + currentBrushType, + "drawMode:", + drawMode + ); + + // Update stamp preview position during drag + if (drawMode === "stamp" && stampPreviewRef.current) { + setStampPreview({ ...stampPreviewRef.current, x, y }); + stampPreviewRef.current = { ...stampPreviewRef.current, x, y }; + return; + } + + if (drawMode === "eraser" || drawMode === "freehand") { + const context = canvas.getContext("2d"); + + // Use advanced brush engine if available + if (brushEngine && currentBrushType !== "normal") { + console.log("Drawing with advanced brush engine:", currentBrushType); + // Ensure context is up to date + brushEngine.updateContext(context); + + // Ensure brush engine has current state + if (brushEngine.brushType !== currentBrushType) { + brushEngine.setBrushType(currentBrushType); + } + if ( + JSON.stringify(brushEngine.brushParams) !== + JSON.stringify(brushParams) + ) { + brushEngine.setBrushParams(brushParams); + } + + brushEngine.draw(x, y, lineWidth, color); + } else { + console.log("Drawing with normal brush"); + // Default drawing behavior + context.lineTo(x, y); + context.stroke(); + context.beginPath(); + context.moveTo(x, y); + } + + tempPathRef.current.push({ x, y }); + } else if (drawMode === "shape" && drawing) { + // update shape preview with adjusted coordinates + if (snapshotRef.current && snapshotRef.current.complete) { + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvasWidth, canvasHeight); + context.drawImage(snapshotRef.current, 0, 0); + } + + drawShapePreview(shapeStart, { x, y }, shapeType, color, lineWidth); + } else if (drawMode === "select" && drawing) { + setSelectionRect({ start: selectionStart, end: { x, y } }); + + if (snapshotRef.current && snapshotRef.current.complete) { + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvasWidth, canvasHeight); + context.drawImage(snapshotRef.current, 0, 0); + } + + const context = canvas.getContext("2d"); + context.save(); + context.strokeStyle = "blue"; + context.lineWidth = 1; + context.setLineDash([6, 3]); + + const s = selectionStart; + const selX = Math.min(s.x, x); + const selY = Math.min(s.y, y); + const selWidth = Math.abs(x - s.x); + const selHeight = Math.abs(y - s.y); + + context.strokeRect(selX, selY, selWidth, selHeight); + context.restore(); + } + }; + + const stopDrawingHandler = async (e) => { + if (isPanning && e.button === 1) { + setIsPanning(false); + return; + } + if (!drawing) return; + setDrawing(false); + + if (!editingEnabled) { + tempPathRef.current = []; + return; + } + + snapshotRef.current = null; + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const finalX = e.clientX - rect.left; + const finalY = e.clientY - rect.top; + + if (drawMode === "eraser" || drawMode === "freehand") { + const newDrawing = new Drawing( + `drawing_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + color, + lineWidth, + tempPathRef.current, + Date.now(), + currentUser, + { + brushStyle: brushStyle, + brushType: currentBrushType, + brushParams: brushParams, + drawingType: "stroke", + } + ); + newDrawing.roomId = currentRoomId; + newDrawing.brushType = currentBrushType; + newDrawing.brushParams = brushParams; + + setUndoStack((prev) => [...prev, newDrawing]); + setRedoStack([]); + + try { + userData.addDrawing(newDrawing); + // Add to pending drawings for immediate display (optimistic UI) + setPendingDrawings((prev) => [...prev, newDrawing]); + + // Use requestAnimationFrame for immediate, smooth redraw + requestAnimationFrame(() => { + drawAllDrawings(); + }); + + // Queue the submission instead of submitting immediately + const submitTask = async () => { + try { + console.log("Submitting queued stroke:", { + drawingId: newDrawing.drawingId, + pathLength: tempPathRef.current.length, + }); + + await submitToDatabase( + newDrawing, + auth, + { + roomId: currentRoomId, + roomType, + }, + setUndoAvailable, + setRedoAvailable + ); + + // Don't remove from pending here - let mergedRefreshCanvas or socket confirmation handle it + + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + } catch (error) { + console.error("Error during queued freehand submission:", error); + // On error, remove the failed stroke from pending + setPendingDrawings((prev) => + prev.filter((d) => d.drawingId !== newDrawing.drawingId) + ); + handleAuthError(error); + } + }; + + submissionQueueRef.current.push(submitTask); + processSubmissionQueue(); + } catch (error) { + console.error("Error preparing freehand stroke:", error); + handleAuthError(error); + } finally { + setIsRefreshing(false); + } + tempPathRef.current = []; + + // If shape completion mode is ON, ask for a suggestion + if (shapeCompletionEnabled) { + setShapeCompletionTrigger(t => t + 1); + } + } else if (drawMode === "shape") { + if (!shapeStart) { + return; + } + + const finalEnd = { x: finalX, y: finalY }; + const context = canvas.getContext("2d"); + + context.save(); + context.fillStyle = color; + context.lineWidth = lineWidth; + context.setLineDash([]); + if (shapeType === "circle") { + const radius = Math.sqrt( + (finalEnd.x - shapeStart.x) ** 2 + (finalEnd.y - shapeStart.y) ** 2 + ); + + context.beginPath(); + context.arc(shapeStart.x, shapeStart.y, radius, 0, Math.PI * 2); + context.fill(); + } else if (shapeType === "rectangle") { + context.fillRect( + shapeStart.x, + shapeStart.y, + finalEnd.x - shapeStart.x, + finalEnd.y - shapeStart.y + ); + } else if (shapeType === "hexagon") { + const radius = Math.sqrt( + (finalEnd.x - shapeStart.x) ** 2 + (finalEnd.y - shapeStart.y) ** 2 + ); + context.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i; + const xPoint = shapeStart.x + radius * Math.cos(angle); + const yPoint = shapeStart.y + radius * Math.sin(angle); + + if (i === 0) context.moveTo(xPoint, yPoint); + else context.lineTo(xPoint, yPoint); + } + + context.closePath(); + context.fill(); + } else if (shapeType === "line") { + context.beginPath(); + context.moveTo(shapeStart.x, shapeStart.y); + context.lineTo(finalEnd.x, finalEnd.y); + context.strokeStyle = color; + context.lineWidth = lineWidth; + context.lineCap = brushStyle; + context.lineJoin = brushStyle; + context.stroke(); + } + context.restore(); + + const shapeDrawingData = { + tool: "shape", + type: shapeType, + start: shapeStart, + end: finalEnd, + brushStyle: shapeType === "line" ? brushStyle : undefined, + }; + + const newDrawing = new Drawing( + `drawing_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + color, + lineWidth, + shapeDrawingData, + Date.now(), + currentUser, + { + brushStyle: shapeType === "line" ? brushStyle : "round", + brushType: currentBrushType, + brushParams: brushParams, + drawingType: "shape", + } + ); + newDrawing.roomId = currentRoomId; + + userData.addDrawing(newDrawing); + setPendingDrawings((prev) => [...prev, newDrawing]); + + // Use requestAnimationFrame for smooth shape rendering + requestAnimationFrame(() => { + drawAllDrawings(); + }); + + setUndoStack((prev) => [...prev, newDrawing]); + setRedoStack([]); + + // Queue the submission + const submitTask = async () => { + try { + await submitToDatabase( + newDrawing, + auth, + { + roomId: currentRoomId, + roomType, + }, + setUndoAvailable, + setRedoAvailable + ); + + // Don't remove from pending here - let mergedRefreshCanvas or socket confirmation handle it + + // Update undo/redo availability after shape submission + if (currentRoomId) { + checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } + } catch (error) { + console.error("Error during queued shape submission:", error); + // On error, remove the failed stroke from pending + setPendingDrawings((prev) => + prev.filter((d) => d.drawingId !== newDrawing.drawingId) + ); + handleAuthError(error); + } + }; + + submissionQueueRef.current.push(submitTask); + processSubmissionQueue(); + + if (shapeCompletionEnabled) { + setShapeCompletionTrigger(t => t + 1); + } + + setShapeStart(null); + } else if (drawMode === "select") { + setDrawing(false); + + try { + await mergedRefreshCanvas(); + } catch (error) { + console.error("Error during select submission or refresh:", error); + } finally { + setIsRefreshing(false); + } + + mergedRefreshCanvas(); + } else if (drawMode === "stamp" && stampPreviewRef.current) { + // Place stamp at final position on mouseup + const { x, y, stamp, settings } = stampPreviewRef.current; + await placeStamp(x, y, stamp, settings); + + // Clear preview + setStampPreview(null); + stampPreviewRef.current = null; + } + }; + + const openHistoryDialog = () => { + setSelectedUser(""); + setHistoryDialogOpen(true); + }; + + const handleApplyHistory = async (startMs, endMs) => { + // startMs and endMs are epoch ms. If not provided, read from inputs. + const start = + startMs !== undefined + ? startMs + : historyStartInput + ? new Date(historyStartInput).getTime() + : NaN; + const end = + endMs !== undefined + ? endMs + : historyEndInput + ? new Date(historyEndInput).getTime() + : NaN; + + if (isNaN(start) || isNaN(end)) { + showLocalSnack( + "Please select both start and end date/time before applying History Recall." + ); + return; + } + if (start > end) { + showLocalSnack("Invalid time range selected. Make sure start <= end."); + return; + } + + // Deselect any selected user when entering history recall + setSelectedUser(""); + setHistoryRange({ start, end }); + setIsLoading(true); + + // Try to load drawings for the requested time range + await clearCanvasForRefresh(); + // set a temporary historyRange so mergedRefreshCanvas will use it + setHistoryRange({ start, end }); + try { + const backendCount = await backendRefreshCanvas( + serverCountRef.current, + userData, + drawAllDrawings, + start, + end, + { roomId: currentRoomId, auth } + ); + serverCountRef.current = backendCount; + // If no drawings loaded, inform user and rollback historyRange + if (!userData.drawings || userData.drawings.length === 0) { + setHistoryRange(null); + showLocalSnack( + "No drawings were found in that date/time range. Please select another range or exit history recall mode." + ); + return; + } + setHistoryMode(true); + setHistoryDialogOpen(false); + } catch (e) { + console.error("Error applying history range:", e); + setHistoryRange(null); + showLocalSnack( + "An error occurred while loading history. See console for details." + ); + } finally { + setIsLoading(false); + } + }; + + // Auto-refresh when the active room changes + useEffect(() => { + // wipe local cache so we don't flash previous room's strokes + userData.drawings = []; + setIsRefreshing(true); + + // clear what's on screen immediately + try { + if (canvasRef.current) { + const ctx = canvasRef.current.getContext("2d"); + if (ctx) { + ctx.clearRect( + 0, + 0, + canvasRef.current.width, + canvasRef.current.height + ); + } + drawAllDrawings(); + } + } catch { } + + // reload for the new room + (async () => { + try { + await mergedRefreshCanvas(); // already room-aware + } finally { + setIsRefreshing(false); + } + })(); + }, [currentRoomId, canvasRefreshTrigger]); + + const exitHistoryMode = async () => { + // Deselect any selected user when leaving history mode + setSelectedUser(""); + setHistoryMode(false); + setHistoryRange(null); + setIsLoading(true); + try { + await clearCanvasForRefresh(); + serverCountRef.current = await backendRefreshCanvas( + serverCountRef.current, + userData, + drawAllDrawings, + undefined, + undefined, + { roomId: currentRoomId, auth } + ); + } finally { + setIsLoading(false); + } + }; + + const clearCanvas = async () => { + if (!editingEnabled) { + showLocalSnack("Cannot clear canvas in view-only mode."); + return; + } + const canvas = canvasRef.current; + const context = canvas.getContext("2d"); + + context.clearRect(0, 0, canvasWidth, canvasHeight); + + setUserData(initializeUserData()); + setUndoStack([]); + setRedoStack([]); + setPendingDrawings([]); + serverCountRef.current = 0; + }; + + const handleExportCanvas = async () => { + if (!currentRoomId) { + showLocalSnack("Cannot export: not in a room"); + return; + } + + try { + setIsLoading(true); + showLocalSnack("Exporting canvas data..."); + + const { exportRoomCanvas } = await import('../api/rooms'); + console.log('[Export] Calling API with roomId:', currentRoomId); + console.log('[Export] Auth token present:', !!auth?.token); + + const exportData = await exportRoomCanvas(auth?.token, currentRoomId); + + console.log('[Export] Received exportData:', { + exists: !!exportData, + type: typeof exportData, + keys: exportData ? Object.keys(exportData) : [], + hasStrokes: exportData ? !!exportData.strokes : false, + strokeCount: exportData ? exportData.strokeCount : 'N/A' + }); + + if (!exportData) { + console.error('[Export] exportData is null or undefined'); + showLocalSnack("Export failed: no data returned from server"); + return; + } + + if (!exportData.strokes) { + console.error('[Export] exportData.strokes is missing:', exportData); + showLocalSnack(`Export failed: no strokes in response (got ${exportData.strokeCount || 0} count)`); + return; + } + + // Create a downloadable JSON file + const dataStr = JSON.stringify(exportData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `${exportData.roomName || 'canvas'}_export_${Date.now()}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showLocalSnack(`Exported ${exportData.strokeCount} strokes successfully`); + console.log('[Export] Success - downloaded file'); + } catch (error) { + console.error("[Export] Error caught:", error); + console.error("[Export] Error stack:", error.stack); + showLocalSnack(`Export failed: ${error.message || 'Unknown error'}`); + } finally { + setIsLoading(false); + } + }; + + const handleImportCanvas = async () => { + if (!currentRoomId) { + showLocalSnack("Cannot import: not in a room"); + return; + } + + if (!editingEnabled) { + showLocalSnack("Cannot import in view-only mode"); + return; + } + + // Create a file input element + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + setIsLoading(true); + showLocalSnack("Reading import file..."); + + const text = await file.text(); + const importData = JSON.parse(text); + + if (!importData.strokes || !Array.isArray(importData.strokes)) { + showLocalSnack("Invalid import file: missing strokes array"); + return; + } + + // Ask user if they want to clear existing canvas + const clearExisting = window.confirm( + `Import ${importData.strokes.length} strokes?\n\n` + + `Click OK to replace current canvas, or Cancel to merge with existing drawings.` + ); + + showLocalSnack(`Importing ${importData.strokes.length} strokes...`); + + const { importRoomCanvas } = await import('../api/rooms'); + const result = await importRoomCanvas(auth?.token, currentRoomId, importData, clearExisting); + + if (result.status === 'success') { + showLocalSnack( + `Import complete: ${result.imported} imported, ${result.failed} failed`, + 6000 + ); + + // Refresh canvas to show imported data + setTimeout(async () => { + try { + await clearCanvasForRefresh(); + await mergedRefreshCanvas("post-import"); + } catch (error) { + console.error("Error refreshing after import:", error); + } + }, 500); + } else { + showLocalSnack(`Import failed: ${result.message || 'Unknown error'}`); + } + } catch (error) { + console.error("Import error:", error); + showLocalSnack(`Import failed: ${error.message || 'Invalid file format'}`); + } finally { + setIsLoading(false); + } + }; + + input.click(); + }; + + const toggleColorPicker = (event) => { + const viewportHeight = window.innerHeight; + const pickerHeight = 350; + const rect = event.target.getBoundingClientRect(); + const pickerElement = document.querySelector(".Canvas-color-picker"); + + setShowColorPicker(!showColorPicker); + + if (rect.bottom + pickerHeight > viewportHeight && pickerElement) { + pickerElement.classList.add("Canvas-color-picker--adjust-bottom"); + } else if (pickerElement) { + pickerElement.classList.remove("Canvas-color-picker--adjust-bottom"); + } + }; + + const closeColorPicker = () => { + setShowColorPicker(false); + }; + + useEffect(() => { + setIsRefreshing(true); + clearCanvasForRefresh(); + + mergedRefreshCanvas().then(() => { + setTimeout(() => { + setIsRefreshing(false); + }, 500); + }); + }, [selectedUser]); + + useEffect(() => { + setUndoAvailable(undoStack.length > 0); + setRedoAvailable(redoStack.length > 0); + }, [undoStack, redoStack]); + + // Add AI-generated objects to canvas and backend + const addAIGeneratedObjects = async (objects) => { + if (!Array.isArray(objects) || objects.length === 0) { + return; + } + + const created = []; + + for (const obj of objects) { + const newDrawing = new Drawing( + generateId(), + obj.color || '#000000', + obj.lineWidth ?? 2, + obj.pathData, + Date.now(), + currentUser + ); + newDrawing.roomId = currentRoomId; + + userData.addDrawing(newDrawing); + setPendingDrawings(prev => [...prev, newDrawing]); + created.push(newDrawing); + + submissionQueueRef.current.push(async () => { + try { + await submitToDatabase( + newDrawing, + auth, + { roomId: currentRoomId, roomType, skipUndoStack: true }, + setUndoAvailable, + setRedoAvailable + ); + if (currentRoomId) { + checkUndoRedoAvailability(auth, setUndoAvailable, setRedoAvailable, currentRoomId); + } + } catch (e) { + console.error("AI object save failed:", e); + setPendingDrawings(prev => prev.filter(d => d.drawingId !== newDrawing.drawingId)); + handleAuthError(e); + } + }); + } + + if (created.length > 0) { + setUndoStack(prev => [...prev, ...created]); + setRedoStack([]); + } + + lastDrawnStateRef.current = null; + requestAnimationFrame(() => { drawAllDrawings(); }); + processSubmissionQueue(); + + setRedoStack([]); + }; + + function getVisibleCanvasBounds(padding=50) { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + const vx0 = 0, vy0 = 0; + const vx1 = window.innerWidth; + const vy1 = window.innerHeight; + + const ix0 = Math.max(rect.left, vx0); + const iy0 = Math.max(rect.top, vy0); + const ix1 = Math.min(rect.right, vx1); + const iy1 = Math.min(rect.bottom,vy1); + + if (ix1 <= ix0 || iy1 <= iy0) return { x: 0, y: 0, width: 0, height: 0 }; + + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + // Convert intersection to canvas space + const x = (ix0 - rect.left) * scaleX; + const y = (iy0 - rect.top) * scaleY; + const width = (ix1 - ix0) * scaleX; + const height = (iy1 - iy0) * scaleY; + + // Apply inner padding + const padX = padding * scaleX; + const padY = padding * scaleY; + + const paddedX = Math.max(0, x + padX); + const paddedY = Math.max(0, y + padY); + const paddedWidth = Math.max(0, width - padX * 2); + const paddedHeight = Math.max(0, height - padY * 2); + + return { + x: Math.floor(paddedX), + y: Math.floor(paddedY), + width: Math.floor(Math.min(canvas.width - paddedX, paddedWidth)), + height: Math.floor(Math.min(canvas.height - paddedY, paddedHeight)), + }; + } + + const handleShapeCompletionToggle = (enabled) => { + setShapeCompletionEnabled(enabled); + }; + + // Helper for the beautify handler + const getBeautifyCanvasState = () => { + const canvasBounds = getVisibleCanvasBounds(); + const width = canvasBounds?.width || 1000; + const height = canvasBounds?.height || 1000; + + const allDrawings = [ + ...(userData?.drawings || []), + ...(pendingDrawings || []), + ].filter((d) => d.drawingType !== "filter"); + + const objects = allDrawings.map((d) => ({ + id: d.drawingId, + color: d.color, + lineWidth: d.lineWidth, + pathData: d.pathData, + brushType: d.brushType, + brushStyle: d.brushStyle, + drawingType: d.drawingType, + })); + + return { width, height, objects }; + }; + + const [showToolbar, setShowToolbar] = useState(true); + const [hoverToolbar, setHoverToolbar] = useState(false); + + return ( +
+ {/* Top header: room name + optional history range + exit button */} + + + {currentRoomName || "Master (not in a room)"} + + + {historyMode && historyRange && ( + + {new Date(historyRange.start).toLocaleString()} —{" "} + {new Date(historyRange.end).toLocaleString()} + + )} + + {currentRoomId && ( + + )} + + + {/* Archived overlay banner - visible when viewOnly (archived or explicit viewer) */} + {viewOnly && ( + + + + Archived — View Only + + {/* Owner-only destructive delete button placed under the banner */} + {isOwner && ( + + + + )} + + + )} + + {/* Wallet disconnected banner - visible when secure room wallet is not connected */} + {roomType === "secure" && !walletConnected && ( + + + + ⚠ Wallet Not Connected — Canvas Locked + + + + )} + + {/* Confirm Destructive Delete dialog (owner-only) */} + { + setConfirmDestructiveOpen(false); + setDestructiveConfirmText(""); + }} + > + Permanently delete room + + + This will permanently delete this room and all its data for every + user. This action is irreversible. + + + To confirm, type DELETE below. + + setDestructiveConfirmText(e.target.value)} + placeholder="Type DELETE to confirm" + sx={{ mt: 1 }} + /> + + + + + + + + + + {/* Stamp preview overlay */} + {stampPreview && ( + + {stampPreview.stamp.emoji ? ( + + {stampPreview.stamp.emoji} + + ) : stampPreview.stamp.image ? ( + Stamp preview + ) : null} + + )} + + setHoverToolbar(true)} + onMouseLeave={() => setHoverToolbar(false)} + > + setShowToolbar((v) => !v)} + sx={{ + position: "absolute", + right: showToolbar ? 0 : -20, + top: "50%", + transform: "translateY(-50%)", + + width: 20, + height: 60, + display: "flex", + alignItems: "center", + justifyContent: "center", + + opacity: hoverToolbar ? 1 : 0, + transition: "opacity 0.2s", + bgcolor: "rgba(0,0,0,0.2)", + cursor: "pointer", + zIndex: 1001, + }} + > + + {showToolbar ? ( + + ) : ( + + )} + + + { + if (!editingEnabled) { + showLocalSnack("Cut is disabled in view-only mode."); + return; + } + showLocalSnack("Cutting selection... This may take a moment."); + try { + const result = await handleCutSelection(); + if (result && result.compositeCutAction) { + setUndoStack((prev) => [...prev, result.compositeCutAction]); + } + setIsRefreshing(true); + showLocalSnack("Syncing cut operation..."); + try { + await mergedRefreshCanvas(); + showLocalSnack("Cut completed successfully!"); + } catch (e) { + console.error("Error syncing cut with server:", e); + showLocalSnack("Cut completed, but sync failed. Try refreshing."); + } finally { + setIsRefreshing(false); + } + } catch (e) { + console.error("Error during cut:", e); + showLocalSnack("Cut operation failed. Please try again."); + } + }} + cutImageData={cutImageData} + setClearDialogOpen={setClearDialogOpen} + /* Export/Import handlers */ + handleExportCanvas={handleExportCanvas} + handleImportCanvas={handleImportCanvas} + /* Advanced brush/stamp/filter props */ + currentBrushType={currentBrushType} + onBrushSelect={handleBrushSelect} + onBrushParamsChange={handleBrushParamsChange} + selectedStamp={selectedStamp} + onStampSelect={handleStampSelect} + onStampChange={handleStampChange} + backendStamps={backendStamps} + onFilterApply={applyFilter} + onFilterPreview={previewFilter} + onFilterUndo={undoFilter} + onClearAllFilters={clearAllFilters} + canUndoFilter={ + !!originalCanvasDataRef.current || + undoStack.some((drawing) => drawing.drawingType === "filter") + } + canClearFilters={hasFilters} + appliedFilters={ + (() => { + const filters = userData.drawings.filter((drawing) => drawing.drawingType === "filter"); + console.log(`[Canvas render] Passing ${filters.length} applied filters to Toolbar`, filters); + return filters; + })() + } + /* History Recall props (required so the toolbar can open/change/exit history mode) */ + openHistoryDialog={openHistoryDialog} + exitHistoryMode={exitHistoryMode} + historyMode={historyMode} + controlsDisabled={!editingEnabled} + onOpenSettings={onOpenSettings} + + // handle showing the AI Assitant Panel + onToggleAI={() => setAiOpen(!aiOpen)} + /> + + setAiOpen(false)} + showPromptInput={(showPrompt, obj) => { + setShowPromptInput(showPrompt); + }} + onShapeCompletionToggle={handleShapeCompletionToggle} + showLocalSnack={showLocalSnack} + addAIGeneratedObjects={addAIGeneratedObjects} + getBeautifyCanvasState={getBeautifyCanvasState} + clearCanvas={async () => { + await clearCanvas(); + try { + const resp = await clearBackendCanvas({ + roomId: currentRoomId, + auth, + }); + + if (resp && resp.clearedAt && currentRoomId) { + roomClearedAtRef.current[currentRoomId] = resp.clearedAt; + } + } catch (e) { + console.error("Failed to clear backend:", e); + } + + try { + await checkUndoRedoAvailability( + auth, + setUndoAvailable, + setRedoAvailable, + currentRoomId + ); + } catch (e) { } + setUserList([]); + try { + setSelectedUser(""); + } catch (e) { + /* ignore if setter missing */ + } + }} + /> + + + + + + {isRefreshing && ( +
+
+
+ )} + + {/* History Recall Dialog */} + setHistoryDialogOpen(false)} + aria-labelledby="history-recall-dialog" + > + + History Recall - Select Date/Time Range + + + + Choose a start and end date/time to recall drawings from + ResilientDB. Only drawings within the selected range will be loaded. + + + setHistoryStartInput(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setHistoryEndInput(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + + + + + + + + + + {historyMode + ? "History Mode Enabled — Canvas Editing Disabled" + : selectedUser && selectedUser !== "" + ? "Viewing Past Drawing History of Selected User — Canvas Editing Disabled" + : ""} + + + + + {/* Loading overlay: fades in/out while drawings load */} + + + + Loading Drawings... + + + + setClearDialogOpen(false)}> + Clear Canvas + + + Are you sure you want to clear the canvas for everyone? + + + + + + + + + {/* Command Palette - Quick command search and execution */} + setCommandPaletteOpen(false)} + commands={commandRegistry.getAll()} + onExecute={(command) => { + try { + command.action(); + } catch (error) { + console.error('[Canvas] Error executing command:', error); + showLocalSnack('Error executing command'); + } + }} + /> + + {/* Keyboard Shortcuts Help Dialog */} + setShortcutsHelpOpen(false)} + shortcuts={shortcutManagerRef.current?.getAllShortcuts() || []} + /> + + + + +
+ ); +} + +export default Canvas; diff --git a/frontend/src/components/Toolbar.js b/frontend/src/components/Toolbar.js index 59bb103c..bb776490 100644 --- a/frontend/src/components/Toolbar.js +++ b/frontend/src/components/Toolbar.js @@ -21,6 +21,8 @@ import ShapeMenu from "../lib/shapeMenu"; import BrushPanel from "./BrushEditor/BrushPanel"; import MixerPanel from "./Mixer/MixerPanel"; import StampPanel from "./Stamps/StampPanel"; +import PsychologyIcon from "@mui/icons-material/Psychology"; + const actionButtonSX = { borderRadius: 1, @@ -76,7 +78,8 @@ const Toolbar = ({ onClearAllFilters, canUndoFilter, canClearFilters, - appliedFilters + appliedFilters, + onToggleAI }) => { const [tool, setTool] = useState(null); const [anchorEl, setAnchorEl] = useState(null); @@ -334,6 +337,18 @@ const Toolbar = ({ + + + + + + + + { + setLoading(true); + setError(null); + + try { + const res = await fetch(`http://127.0.0.1:10010/api/ai_assistant/${endpoint}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + + const data = await res.json(); + setResult(data); + return data; + } catch (err) { + setError(err.message); + console.error("AI assistant error:", err); + } finally { + setLoading(false); + } + }; + + // Wrapper methods for each route + const textToDrawing = (prompt, canvasState) => callAIAssistant("drawing", { prompt, canvasState }); + const shapeCompletion = (canvasState) => callAIAssistant("complete", { canvasState }); + const textToImage = (prompt) => callAIAssistant("image", { prompt }); + const beautifySketch = (canvasState) => callAIAssistant("beautify", { canvasState }); + + return { + aiAssistLoading: loading, + aiAssistError: error, + aiAssistResult: result, + textToDrawing, + shapeCompletion, + textToImage, + beautifySketch, + }; +} diff --git a/frontend/src/styles/ai-assistant.css b/frontend/src/styles/ai-assistant.css new file mode 100644 index 00000000..828e4f7e --- /dev/null +++ b/frontend/src/styles/ai-assistant.css @@ -0,0 +1,61 @@ +.ai-assistant-panel-container { + display: flex; + min-width: 250px; + padding: 0 10px; + margin-top: 10px; + align-items: center; + justify-content: space-between; + background-color: #25D8C5; + border-top-right-radius: 15px; + border-bottom-right-radius: 15px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.ai-assistant-panel-container-open { + position: absolute; + left: 0; + top: -75px; + transition-duration: .4s; +} + +.ai-assistant-panel-container-close { + position: absolute; + left: -300px; + top: -75px; + transition-duration: .4s; +} + +.ai-asisstant-panel-item { + padding: 0 5px; + margin: 5px; + border-radius: 8px; +} + +.ai-asisstant-panel-item-active { + padding: 0 5px; + margin: 5px; + border-radius: 8px; + background-color: rgba(0, 0, 0, .1); +} + +.ai-asisstant-panel-item:hover { + background-color: rgba(0, 0, 0, .05); + transition-duration: .3s; +} + +.prompt-input-container { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + z-index: 9999; + background: rgba(255, 255, 255, 0.95); + padding: 10px 14px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(6px); +} diff --git a/frontend/src/utils/aiStrokeAdapter.js b/frontend/src/utils/aiStrokeAdapter.js new file mode 100644 index 00000000..ae3d48bd --- /dev/null +++ b/frontend/src/utils/aiStrokeAdapter.js @@ -0,0 +1,77 @@ +import { Drawing } from "../lib/drawing"; + +const isValidPoint = (p) => + p && + Number.isFinite(p.x) && + Number.isFinite(p.y); + +const toPoint = (p) => ({ + x: Number(p.x), + y: Number(p.y), +}); + +const normalizeColor = (value) => value || "#000000"; +const normalizeLineWidth = (value) => + Number.isFinite(Number(value)) ? Number(value) : 4; + +function itemToPathData(item) { + const type = item.type; + + if (type === "polygon") { + const points = Array.isArray(item.points) + ? item.points.filter(isValidPoint).map(toPoint) + : []; + + if (points.length < 3) { + throw new Error("Polygon must have at least 3 valid points"); + } + + return { + tool: "shape", + type: "polygon", + points, + }; + } + + if (!isValidPoint(item.start) || !isValidPoint(item.end)) { + throw new Error(`${type} must include valid start and end points`); + } + + return { + tool: "shape", + type, + start: toPoint(item.start), + end: toPoint(item.end), + }; +} + +export function aiPayloadToDrawings({ + aiPayload, + currentUser, + generateId, +}) { + const items = Array.isArray(aiPayload?.items) ? aiPayload.items : []; + + if (!items.length) { + throw new Error("AI payload has no items"); + } + + return items.map((item, index) => { + const drawing = new Drawing( + generateId(`ai_${index}`), + normalizeColor(item.color), + normalizeLineWidth(item.lineWidth), + itemToPathData(item), + Date.now() + index, + currentUser, + { + brushStyle: "round", + brushType: "normal", + brushParams: {}, + drawingType: "shape", + } + ); + + return drawing; + }); +} \ No newline at end of file diff --git a/scripts/run_all_tests_unified.sh b/scripts/run_all_tests_unified.sh index f302eec5..5e8db917 100755 --- a/scripts/run_all_tests_unified.sh +++ b/scripts/run_all_tests_unified.sh @@ -9,7 +9,7 @@ set -e # Exit on first error # ============================================ # Color Definitions # ============================================ -RED='\033[0;31m' +RED='\033[0;31m'conda deactivate GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m'