Spaces:
Running
Running
t Claude (claude-opus-4-5-thinking) commited on
Commit ·
da90401
1
Parent(s): c5175b4
feat: redesign revision notes system with separate handwritten canvas
Browse files- Add blank white canvas for handwritten revision notes (not overlay)
- Add checkbox to include/exclude notes in generated PDF
- Add delete note functionality
- Improved toolbar with highlighter, undo/redo, arrow shape, more colors
- Add keyboard shortcuts (Ctrl+Z/Y, P/H/S/T keys)
- Notes displayed below question image in PDF (60/40 split)
- Add include_note_in_pdf column to database
- Better UI/UX with tool groups and stylus-only mode
Co-Authored-By: Claude (claude-opus-4-5-thinking) <noreply@anthropic.com>
- database.py +11 -0
- image_routes.py +113 -1
- routes/pdf_ops.py +18 -2
- routes/questions.py +1 -1
- templates/question_entry_v2.html +607 -2
- utils.py +44 -3
database.py
CHANGED
|
@@ -329,8 +329,19 @@ def setup_database():
|
|
| 329 |
except sqlite3.OperationalError:
|
| 330 |
cursor.execute("ALTER TABLE images ADD COLUMN box_id TEXT")
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
try:
|
| 333 |
cursor.execute("SELECT session_type FROM sessions LIMIT 1")
|
|
|
|
| 334 |
except sqlite3.OperationalError:
|
| 335 |
cursor.execute("ALTER TABLE sessions ADD COLUMN session_type TEXT DEFAULT 'standard'")
|
| 336 |
|
|
|
|
| 329 |
except sqlite3.OperationalError:
|
| 330 |
cursor.execute("ALTER TABLE images ADD COLUMN box_id TEXT")
|
| 331 |
|
| 332 |
+
try:
|
| 333 |
+
cursor.execute("SELECT note_filename FROM images LIMIT 1")
|
| 334 |
+
except sqlite3.OperationalError:
|
| 335 |
+
cursor.execute("ALTER TABLE images ADD COLUMN note_filename TEXT")
|
| 336 |
+
|
| 337 |
+
try:
|
| 338 |
+
cursor.execute("SELECT include_note_in_pdf FROM images LIMIT 1")
|
| 339 |
+
except sqlite3.OperationalError:
|
| 340 |
+
cursor.execute("ALTER TABLE images ADD COLUMN include_note_in_pdf INTEGER DEFAULT 1")
|
| 341 |
+
|
| 342 |
try:
|
| 343 |
cursor.execute("SELECT session_type FROM sessions LIMIT 1")
|
| 344 |
+
|
| 345 |
except sqlite3.OperationalError:
|
| 346 |
cursor.execute("ALTER TABLE sessions ADD COLUMN session_type TEXT DEFAULT 'standard'")
|
| 347 |
|
image_routes.py
CHANGED
|
@@ -1,4 +1,8 @@
|
|
| 1 |
-
from flask import Blueprint, send_from_directory, current_app
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
image_bp = Blueprint('image_bp', __name__)
|
| 4 |
|
|
@@ -22,3 +26,111 @@ def serve_neetprep_processed_image(filename):
|
|
| 22 |
def serve_neetprep_tmp_image(filename):
|
| 23 |
current_app.logger.info(f"Serving /neetprep/tmp image: {filename}")
|
| 24 |
return send_from_directory(current_app.config['TEMP_FOLDER'], filename)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, send_from_directory, current_app, request, jsonify
|
| 2 |
+
from flask_login import login_required, current_user
|
| 3 |
+
from utils import get_db_connection
|
| 4 |
+
import os
|
| 5 |
+
from datetime import datetime
|
| 6 |
|
| 7 |
image_bp = Blueprint('image_bp', __name__)
|
| 8 |
|
|
|
|
| 26 |
def serve_neetprep_tmp_image(filename):
|
| 27 |
current_app.logger.info(f"Serving /neetprep/tmp image: {filename}")
|
| 28 |
return send_from_directory(current_app.config['TEMP_FOLDER'], filename)
|
| 29 |
+
|
| 30 |
+
@image_bp.route('/save_note_image', methods=['POST'])
|
| 31 |
+
@login_required
|
| 32 |
+
def save_note_image():
|
| 33 |
+
try:
|
| 34 |
+
if 'image' not in request.files:
|
| 35 |
+
return jsonify({'error': 'No image file provided'}), 400
|
| 36 |
+
|
| 37 |
+
file = request.files['image']
|
| 38 |
+
image_id = request.form.get('image_id')
|
| 39 |
+
session_id = request.form.get('session_id')
|
| 40 |
+
|
| 41 |
+
if not image_id or not session_id:
|
| 42 |
+
return jsonify({'error': 'Missing image_id or session_id'}), 400
|
| 43 |
+
|
| 44 |
+
# Validate ownership
|
| 45 |
+
conn = get_db_connection()
|
| 46 |
+
img = conn.execute("SELECT i.id, s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.id = ?", (image_id,)).fetchone()
|
| 47 |
+
|
| 48 |
+
if not img or img['user_id'] != current_user.id:
|
| 49 |
+
conn.close()
|
| 50 |
+
return jsonify({'error': 'Unauthorized or image not found'}), 403
|
| 51 |
+
|
| 52 |
+
# Save the file
|
| 53 |
+
filename = f"note_{session_id}_{image_id}_{int(datetime.now().timestamp())}.png"
|
| 54 |
+
save_path = os.path.join(current_app.config['PROCESSED_FOLDER'], filename)
|
| 55 |
+
file.save(save_path)
|
| 56 |
+
|
| 57 |
+
# Update DB
|
| 58 |
+
conn.execute("UPDATE images SET note_filename = ? WHERE id = ?", (filename, image_id))
|
| 59 |
+
conn.commit()
|
| 60 |
+
conn.close()
|
| 61 |
+
|
| 62 |
+
return jsonify({'success': True, 'filename': filename})
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
current_app.logger.error(f"Error saving note: {e}")
|
| 66 |
+
return jsonify({'error': str(e)}), 500
|
| 67 |
+
|
| 68 |
+
@image_bp.route('/toggle_note_in_pdf', methods=['POST'])
|
| 69 |
+
@login_required
|
| 70 |
+
def toggle_note_in_pdf():
|
| 71 |
+
"""Toggle whether a note should be included in the generated PDF."""
|
| 72 |
+
try:
|
| 73 |
+
data = request.json
|
| 74 |
+
image_id = data.get('image_id')
|
| 75 |
+
include = data.get('include', True)
|
| 76 |
+
|
| 77 |
+
if not image_id:
|
| 78 |
+
return jsonify({'error': 'Missing image_id'}), 400
|
| 79 |
+
|
| 80 |
+
conn = get_db_connection()
|
| 81 |
+
img = conn.execute("""
|
| 82 |
+
SELECT i.id, s.user_id FROM images i
|
| 83 |
+
JOIN sessions s ON i.session_id = s.id WHERE i.id = ?
|
| 84 |
+
""", (image_id,)).fetchone()
|
| 85 |
+
|
| 86 |
+
if not img or img['user_id'] != current_user.id:
|
| 87 |
+
conn.close()
|
| 88 |
+
return jsonify({'error': 'Unauthorized'}), 403
|
| 89 |
+
|
| 90 |
+
conn.execute("UPDATE images SET include_note_in_pdf = ? WHERE id = ?",
|
| 91 |
+
(1 if include else 0, image_id))
|
| 92 |
+
conn.commit()
|
| 93 |
+
conn.close()
|
| 94 |
+
|
| 95 |
+
return jsonify({'success': True, 'include': include})
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
current_app.logger.error(f"Error toggling note in PDF: {e}")
|
| 99 |
+
return jsonify({'error': str(e)}), 500
|
| 100 |
+
|
| 101 |
+
@image_bp.route('/delete_note', methods=['POST'])
|
| 102 |
+
@login_required
|
| 103 |
+
def delete_note():
|
| 104 |
+
"""Delete a note image for a question."""
|
| 105 |
+
try:
|
| 106 |
+
data = request.json
|
| 107 |
+
image_id = data.get('image_id')
|
| 108 |
+
|
| 109 |
+
if not image_id:
|
| 110 |
+
return jsonify({'error': 'Missing image_id'}), 400
|
| 111 |
+
|
| 112 |
+
conn = get_db_connection()
|
| 113 |
+
img = conn.execute("""
|
| 114 |
+
SELECT i.id, i.note_filename, s.user_id FROM images i
|
| 115 |
+
JOIN sessions s ON i.session_id = s.id WHERE i.id = ?
|
| 116 |
+
""", (image_id,)).fetchone()
|
| 117 |
+
|
| 118 |
+
if not img or img['user_id'] != current_user.id:
|
| 119 |
+
conn.close()
|
| 120 |
+
return jsonify({'error': 'Unauthorized'}), 403
|
| 121 |
+
|
| 122 |
+
# Delete the file if it exists
|
| 123 |
+
if img['note_filename']:
|
| 124 |
+
note_path = os.path.join(current_app.config['PROCESSED_FOLDER'], img['note_filename'])
|
| 125 |
+
if os.path.exists(note_path):
|
| 126 |
+
os.remove(note_path)
|
| 127 |
+
|
| 128 |
+
conn.execute("UPDATE images SET note_filename = NULL WHERE id = ?", (image_id,))
|
| 129 |
+
conn.commit()
|
| 130 |
+
conn.close()
|
| 131 |
+
|
| 132 |
+
return jsonify({'success': True})
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
current_app.logger.error(f"Error deleting note: {e}")
|
| 136 |
+
return jsonify({'error': str(e)}), 500
|
routes/pdf_ops.py
CHANGED
|
@@ -49,8 +49,16 @@ def generate_preview():
|
|
| 49 |
session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (sid,)).fetchone()
|
| 50 |
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 51 |
conn.close(); return jsonify({'error': 'Unauthorized'}), 403
|
| 52 |
-
all_questions = [dict(row) for row in conn.execute("
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
all_questions.extend(data.get('miscellaneous_questions', []))
|
| 55 |
filter_type = data.get('filter_type', 'all')
|
| 56 |
filtered = [q for q in all_questions if filter_type == 'all' or q['status'] == filter_type]
|
|
@@ -86,7 +94,15 @@ def generate_pdf():
|
|
| 86 |
session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (sid,)).fetchone()
|
| 87 |
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 88 |
conn.close(); return jsonify({'error': 'Unauthorized'}), 403
|
| 89 |
-
all_questions = [dict(row) for row in conn.execute("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
all_questions.extend(data.get('miscellaneous_questions', []))
|
| 91 |
filter_type = data.get('filter_type', 'all')
|
| 92 |
filtered = [q for q in all_questions if filter_type == 'all' or q['status'] == filter_type]
|
|
|
|
| 49 |
session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (sid,)).fetchone()
|
| 50 |
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 51 |
conn.close(); return jsonify({'error': 'Unauthorized'}), 403
|
| 52 |
+
all_questions = [dict(row) for row in conn.execute("""
|
| 53 |
+
SELECT q.*, i.filename, i.processed_filename, i.note_filename, i.include_note_in_pdf
|
| 54 |
+
FROM questions q JOIN images i ON q.image_id = i.id
|
| 55 |
+
WHERE q.session_id = ? ORDER BY i.id
|
| 56 |
+
""", (sid,)).fetchall()]
|
| 57 |
conn.close()
|
| 58 |
+
# Only include note_filename if include_note_in_pdf is true
|
| 59 |
+
for q in all_questions:
|
| 60 |
+
if not q.get('include_note_in_pdf', 1):
|
| 61 |
+
q['note_filename'] = None
|
| 62 |
all_questions.extend(data.get('miscellaneous_questions', []))
|
| 63 |
filter_type = data.get('filter_type', 'all')
|
| 64 |
filtered = [q for q in all_questions if filter_type == 'all' or q['status'] == filter_type]
|
|
|
|
| 94 |
session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (sid,)).fetchone()
|
| 95 |
if not session_owner or session_owner['user_id'] != current_user.id:
|
| 96 |
conn.close(); return jsonify({'error': 'Unauthorized'}), 403
|
| 97 |
+
all_questions = [dict(row) for row in conn.execute("""
|
| 98 |
+
SELECT q.*, i.filename, i.processed_filename, i.note_filename, i.include_note_in_pdf
|
| 99 |
+
FROM questions q JOIN images i ON q.image_id = i.id
|
| 100 |
+
WHERE q.session_id = ? ORDER BY i.id
|
| 101 |
+
""", (sid,)).fetchall()]
|
| 102 |
+
# Only include note_filename if include_note_in_pdf is true
|
| 103 |
+
for q in all_questions:
|
| 104 |
+
if not q.get('include_note_in_pdf', 1):
|
| 105 |
+
q['note_filename'] = None
|
| 106 |
all_questions.extend(data.get('miscellaneous_questions', []))
|
| 107 |
filter_type = data.get('filter_type', 'all')
|
| 108 |
filtered = [q for q in all_questions if filter_type == 'all' or q['status'] == filter_type]
|
routes/questions.py
CHANGED
|
@@ -326,7 +326,7 @@ def question_entry_v2(session_id):
|
|
| 326 |
session_data = conn.execute('SELECT original_filename, subject, tags, notes FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone()
|
| 327 |
if not session_data:
|
| 328 |
conn.close(); flash("Session not found or you don't have permission to access it.", "warning"); return redirect(url_for('dashboard.dashboard'))
|
| 329 |
-
images = conn.execute("""SELECT i.id, i.processed_filename, q.question_number, q.status, q.marked_solution, q.actual_solution
|
| 330 |
FROM images i
|
| 331 |
LEFT JOIN questions q ON i.id = q.image_id
|
| 332 |
WHERE i.session_id = ? AND i.image_type = 'cropped'
|
|
|
|
| 326 |
session_data = conn.execute('SELECT original_filename, subject, tags, notes FROM sessions WHERE id = ? AND user_id = ?', (session_id, current_user.id)).fetchone()
|
| 327 |
if not session_data:
|
| 328 |
conn.close(); flash("Session not found or you don't have permission to access it.", "warning"); return redirect(url_for('dashboard.dashboard'))
|
| 329 |
+
images = conn.execute("""SELECT i.id, i.processed_filename, i.note_filename, i.include_note_in_pdf, q.question_number, q.status, q.marked_solution, q.actual_solution
|
| 330 |
FROM images i
|
| 331 |
LEFT JOIN questions q ON i.id = q.image_id
|
| 332 |
WHERE i.session_id = ? AND i.image_type = 'cropped'
|
templates/question_entry_v2.html
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
{% block title %}Enter Question Details (V2){% endblock %}
|
| 4 |
|
| 5 |
{% block head %}
|
|
|
|
| 6 |
<style>
|
| 7 |
.keyboard-hint { font-size: 0.8em; color: #6c757d; margin-top: 2px; }
|
| 8 |
.status-buttons { display: flex; gap: 0.25rem; margin-top: 0.25rem; }
|
|
@@ -55,6 +56,79 @@
|
|
| 55 |
.ts-dropdown .option:hover, .ts-dropdown .active {
|
| 56 |
background: #495057;
|
| 57 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</style>
|
| 59 |
{% endblock %}
|
| 60 |
|
|
@@ -120,8 +194,33 @@
|
|
| 120 |
<i class="bi bi-trash"></i> Delete
|
| 121 |
</button>
|
| 122 |
</legend>
|
| 123 |
-
<div class="col-md-3 mb-3">
|
| 124 |
-
<img src="/image/processed/{{ session_id }}/{{ image.processed_filename }}" class="img-fluid rounded" alt="Cropped Question {{ loop.index }}">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
</div>
|
| 126 |
<div class="col-md-9">
|
| 127 |
<div class="row">
|
|
@@ -265,6 +364,101 @@
|
|
| 265 |
</div>
|
| 266 |
</div>
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
<div class="accordion mt-4" id="misc-accordion">
|
| 269 |
<div class="accordion-item bg-dark">
|
| 270 |
<h2 class="accordion-header" id="headingOne">
|
|
@@ -469,6 +663,16 @@
|
|
| 469 |
const sessionId = '{{ session_id }}';
|
| 470 |
let answerKeyData = new Map();
|
| 471 |
let miscellaneousQuestions = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
|
| 473 |
async function initializeTomSelect() {
|
| 474 |
try {
|
|
@@ -496,6 +700,407 @@
|
|
| 496 |
console.error('Error initializing Tom Select:', err);
|
| 497 |
}
|
| 498 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
|
| 500 |
function saveSettings() {
|
| 501 |
// Session-specific settings
|
|
|
|
| 3 |
{% block title %}Enter Question Details (V2){% endblock %}
|
| 4 |
|
| 5 |
{% block head %}
|
| 6 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
|
| 7 |
<style>
|
| 8 |
.keyboard-hint { font-size: 0.8em; color: #6c757d; margin-top: 2px; }
|
| 9 |
.status-buttons { display: flex; gap: 0.25rem; margin-top: 0.25rem; }
|
|
|
|
| 56 |
.ts-dropdown .option:hover, .ts-dropdown .active {
|
| 57 |
background: #495057;
|
| 58 |
}
|
| 59 |
+
|
| 60 |
+
/* Notes Modal Styles */
|
| 61 |
+
#notes-canvas-container {
|
| 62 |
+
width: 100%;
|
| 63 |
+
height: calc(100vh - 180px);
|
| 64 |
+
background: #f8f9fa;
|
| 65 |
+
border: 1px solid #495057;
|
| 66 |
+
overflow: hidden;
|
| 67 |
+
position: relative;
|
| 68 |
+
display: flex;
|
| 69 |
+
justify-content: center;
|
| 70 |
+
align-items: center;
|
| 71 |
+
}
|
| 72 |
+
.notes-toolbar {
|
| 73 |
+
display: flex;
|
| 74 |
+
gap: 8px;
|
| 75 |
+
padding: 12px 16px;
|
| 76 |
+
background: linear-gradient(180deg, #2c3034, #212529);
|
| 77 |
+
border-bottom: 1px solid #495057;
|
| 78 |
+
flex-wrap: wrap;
|
| 79 |
+
align-items: center;
|
| 80 |
+
}
|
| 81 |
+
.notes-toolbar .tool-group {
|
| 82 |
+
display: flex;
|
| 83 |
+
gap: 4px;
|
| 84 |
+
padding: 0 8px;
|
| 85 |
+
border-right: 1px solid #495057;
|
| 86 |
+
}
|
| 87 |
+
.notes-toolbar .tool-group:last-child {
|
| 88 |
+
border-right: none;
|
| 89 |
+
}
|
| 90 |
+
.notes-toolbar .btn {
|
| 91 |
+
min-width: 40px;
|
| 92 |
+
height: 40px;
|
| 93 |
+
display: flex;
|
| 94 |
+
align-items: center;
|
| 95 |
+
justify-content: center;
|
| 96 |
+
}
|
| 97 |
+
.color-swatch {
|
| 98 |
+
width: 28px;
|
| 99 |
+
height: 28px;
|
| 100 |
+
border-radius: 50%;
|
| 101 |
+
cursor: pointer;
|
| 102 |
+
border: 2px solid transparent;
|
| 103 |
+
transition: transform 0.15s, border-color 0.15s;
|
| 104 |
+
}
|
| 105 |
+
.color-swatch:hover { transform: scale(1.1); }
|
| 106 |
+
.color-swatch.active { border-color: #fff; transform: scale(1.15); box-shadow: 0 0 8px rgba(255,255,255,0.5); }
|
| 107 |
+
|
| 108 |
+
/* Note Card Styles */
|
| 109 |
+
.note-card {
|
| 110 |
+
background: rgba(13, 202, 240, 0.1);
|
| 111 |
+
border: 1px solid #0dcaf0;
|
| 112 |
+
border-radius: 8px;
|
| 113 |
+
padding: 8px;
|
| 114 |
+
margin-bottom: 8px;
|
| 115 |
+
}
|
| 116 |
+
.note-thumbnail {
|
| 117 |
+
max-height: 80px;
|
| 118 |
+
object-fit: contain;
|
| 119 |
+
border-radius: 4px;
|
| 120 |
+
}
|
| 121 |
+
.note-actions {
|
| 122 |
+
display: flex;
|
| 123 |
+
gap: 4px;
|
| 124 |
+
margin-top: 6px;
|
| 125 |
+
}
|
| 126 |
+
.include-pdf-toggle {
|
| 127 |
+
cursor: pointer;
|
| 128 |
+
}
|
| 129 |
+
.include-pdf-toggle input:checked + .form-check-label {
|
| 130 |
+
color: #0dcaf0;
|
| 131 |
+
}
|
| 132 |
</style>
|
| 133 |
{% endblock %}
|
| 134 |
|
|
|
|
| 194 |
<i class="bi bi-trash"></i> Delete
|
| 195 |
</button>
|
| 196 |
</legend>
|
| 197 |
+
<div class="col-md-3 mb-3 text-center">
|
| 198 |
+
<img src="/image/processed/{{ session_id }}/{{ image.processed_filename }}" class="img-fluid rounded mb-2" alt="Cropped Question {{ loop.index }}">
|
| 199 |
+
{% if image.note_filename %}
|
| 200 |
+
<div class="note-card">
|
| 201 |
+
<div class="position-relative">
|
| 202 |
+
<img src="/processed/{{ image.note_filename }}" class="img-fluid note-thumbnail" alt="Note">
|
| 203 |
+
</div>
|
| 204 |
+
<div class="note-actions flex-wrap justify-content-center">
|
| 205 |
+
<div class="form-check form-switch include-pdf-toggle">
|
| 206 |
+
<input class="form-check-input" type="checkbox" id="include_note_{{ image.id }}"
|
| 207 |
+
{% if image.include_note_in_pdf is none or image.include_note_in_pdf %}checked{% endif %}
|
| 208 |
+
onchange="toggleNoteInPdf('{{ image.id }}', this.checked)">
|
| 209 |
+
<label class="form-check-label small" for="include_note_{{ image.id }}">In PDF</label>
|
| 210 |
+
</div>
|
| 211 |
+
<button type="button" class="btn btn-sm btn-outline-info" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}', '{{ image.note_filename }}')" title="Edit Note">
|
| 212 |
+
<i class="bi bi-pencil"></i>
|
| 213 |
+
</button>
|
| 214 |
+
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteNote('{{ image.id }}')" title="Delete Note">
|
| 215 |
+
<i class="bi bi-trash"></i>
|
| 216 |
+
</button>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
{% else %}
|
| 220 |
+
<button type="button" class="btn btn-sm btn-outline-info w-100" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}', '')">
|
| 221 |
+
<i class="bi bi-pencil-square me-1"></i>Add Revision Notes
|
| 222 |
+
</button>
|
| 223 |
+
{% endif %}
|
| 224 |
</div>
|
| 225 |
<div class="col-md-9">
|
| 226 |
<div class="row">
|
|
|
|
| 364 |
</div>
|
| 365 |
</div>
|
| 366 |
|
| 367 |
+
<!-- Notes Modal -->
|
| 368 |
+
<div class="modal fade" id="notesModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
|
| 369 |
+
<div class="modal-dialog modal-fullscreen">
|
| 370 |
+
<div class="modal-content bg-dark text-white">
|
| 371 |
+
<div class="modal-header py-2 border-secondary">
|
| 372 |
+
<h5 class="modal-title"><i class="bi bi-pencil-fill me-2"></i>Add Revision Notes</h5>
|
| 373 |
+
<div class="d-flex gap-2">
|
| 374 |
+
<button class="btn btn-success" onclick="saveNotes()">
|
| 375 |
+
<i class="bi bi-check-lg me-1"></i>Save Notes
|
| 376 |
+
</button>
|
| 377 |
+
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
|
| 378 |
+
<i class="bi bi-x-lg me-1"></i>Cancel
|
| 379 |
+
</button>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
<div class="notes-toolbar">
|
| 383 |
+
<!-- Drawing Tools -->
|
| 384 |
+
<div class="tool-group">
|
| 385 |
+
<button class="btn btn-outline-light active" id="tool-pencil" onclick="setTool('pencil')" title="Pencil (P)">
|
| 386 |
+
<i class="bi bi-pencil"></i>
|
| 387 |
+
</button>
|
| 388 |
+
<button class="btn btn-outline-light" id="tool-highlighter" onclick="setTool('highlighter')" title="Highlighter (H)">
|
| 389 |
+
<i class="bi bi-highlighter"></i>
|
| 390 |
+
</button>
|
| 391 |
+
<button class="btn btn-outline-light" id="tool-select" onclick="setTool('select')" title="Select (S)">
|
| 392 |
+
<i class="bi bi-cursor"></i>
|
| 393 |
+
</button>
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
<!-- Colors -->
|
| 397 |
+
<div class="tool-group">
|
| 398 |
+
<div class="color-swatch active" style="background: #000000;" data-color="#000000" onclick="setColor('#000000')" title="Black"></div>
|
| 399 |
+
<div class="color-swatch" style="background: #dc3545;" data-color="#dc3545" onclick="setColor('#dc3545')" title="Red"></div>
|
| 400 |
+
<div class="color-swatch" style="background: #0d6efd;" data-color="#0d6efd" onclick="setColor('#0d6efd')" title="Blue"></div>
|
| 401 |
+
<div class="color-swatch" style="background: #198754;" data-color="#198754" onclick="setColor('#198754')" title="Green"></div>
|
| 402 |
+
<div class="color-swatch" style="background: #ffc107;" data-color="#ffc107" onclick="setColor('#ffc107')" title="Yellow"></div>
|
| 403 |
+
<div class="color-swatch" style="background: #6f42c1;" data-color="#6f42c1" onclick="setColor('#6f42c1')" title="Purple"></div>
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
<!-- Brush Size -->
|
| 407 |
+
<div class="tool-group">
|
| 408 |
+
<label class="text-muted small me-1 d-flex align-items-center">Size:</label>
|
| 409 |
+
<input type="range" class="form-range" style="width: 80px;" min="1" max="30" value="3" id="brush-size" oninput="setBrushSize(this.value)">
|
| 410 |
+
<span class="text-white small ms-1" id="brush-size-val">3</span>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
<!-- Shapes -->
|
| 414 |
+
<div class="tool-group">
|
| 415 |
+
<button class="btn btn-outline-light" onclick="addShape('rect')" title="Rectangle">
|
| 416 |
+
<i class="bi bi-square"></i>
|
| 417 |
+
</button>
|
| 418 |
+
<button class="btn btn-outline-light" onclick="addShape('circle')" title="Circle">
|
| 419 |
+
<i class="bi bi-circle"></i>
|
| 420 |
+
</button>
|
| 421 |
+
<button class="btn btn-outline-light" onclick="addShape('arrow')" title="Arrow">
|
| 422 |
+
<i class="bi bi-arrow-up-right"></i>
|
| 423 |
+
</button>
|
| 424 |
+
<button class="btn btn-outline-light" onclick="addText()" title="Add Text (T)">
|
| 425 |
+
<i class="bi bi-fonts"></i>
|
| 426 |
+
</button>
|
| 427 |
+
</div>
|
| 428 |
+
|
| 429 |
+
<!-- Actions -->
|
| 430 |
+
<div class="tool-group">
|
| 431 |
+
<button class="btn btn-outline-warning" onclick="undoCanvas()" title="Undo (Ctrl+Z)">
|
| 432 |
+
<i class="bi bi-arrow-counterclockwise"></i>
|
| 433 |
+
</button>
|
| 434 |
+
<button class="btn btn-outline-warning" onclick="redoCanvas()" title="Redo (Ctrl+Y)">
|
| 435 |
+
<i class="bi bi-arrow-clockwise"></i>
|
| 436 |
+
</button>
|
| 437 |
+
<button class="btn btn-outline-danger" onclick="deleteSelected()" title="Delete Selected (Del)">
|
| 438 |
+
<i class="bi bi-trash"></i>
|
| 439 |
+
</button>
|
| 440 |
+
<button class="btn btn-outline-danger" onclick="clearCanvas()" title="Clear All">
|
| 441 |
+
<i class="bi bi-x-circle"></i>
|
| 442 |
+
</button>
|
| 443 |
+
</div>
|
| 444 |
+
|
| 445 |
+
<!-- Pen Only Mode -->
|
| 446 |
+
<div class="tool-group border-0">
|
| 447 |
+
<div class="form-check form-switch">
|
| 448 |
+
<input class="form-check-input" type="checkbox" id="pen-only-mode" onchange="togglePenOnly()">
|
| 449 |
+
<label class="form-check-label small" for="pen-only-mode">Stylus Only</label>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
<div class="modal-body p-0" id="notes-canvas-body">
|
| 454 |
+
<div id="notes-canvas-container">
|
| 455 |
+
<canvas id="notes-canvas"></canvas>
|
| 456 |
+
</div>
|
| 457 |
+
</div>
|
| 458 |
+
</div>
|
| 459 |
+
</div>
|
| 460 |
+
</div>
|
| 461 |
+
|
| 462 |
<div class="accordion mt-4" id="misc-accordion">
|
| 463 |
<div class="accordion-item bg-dark">
|
| 464 |
<h2 class="accordion-header" id="headingOne">
|
|
|
|
| 663 |
const sessionId = '{{ session_id }}';
|
| 664 |
let answerKeyData = new Map();
|
| 665 |
let miscellaneousQuestions = [];
|
| 666 |
+
|
| 667 |
+
// Canvas Variables
|
| 668 |
+
let canvas;
|
| 669 |
+
let currentNoteImageId = null;
|
| 670 |
+
let penColor = '#000000';
|
| 671 |
+
let penWidth = 3;
|
| 672 |
+
let isPenOnly = false;
|
| 673 |
+
let canvasHistory = [];
|
| 674 |
+
let historyIndex = -1;
|
| 675 |
+
let isHistoryAction = false;
|
| 676 |
|
| 677 |
async function initializeTomSelect() {
|
| 678 |
try {
|
|
|
|
| 700 |
console.error('Error initializing Tom Select:', err);
|
| 701 |
}
|
| 702 |
}
|
| 703 |
+
|
| 704 |
+
// --- CANVAS FUNCTIONS ---
|
| 705 |
+
function openNotesModal(imageId, imageUrl, existingNoteUrl) {
|
| 706 |
+
currentNoteImageId = imageId;
|
| 707 |
+
canvasHistory = [];
|
| 708 |
+
historyIndex = -1;
|
| 709 |
+
|
| 710 |
+
const modal = new bootstrap.Modal(document.getElementById('notesModal'));
|
| 711 |
+
modal.show();
|
| 712 |
+
|
| 713 |
+
document.getElementById('notesModal').addEventListener('shown.bs.modal', function init() {
|
| 714 |
+
initCanvas(existingNoteUrl);
|
| 715 |
+
document.getElementById('notesModal').removeEventListener('shown.bs.modal', init);
|
| 716 |
+
});
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
function initCanvas(existingNoteUrl) {
|
| 720 |
+
// Dispose existing canvas if any
|
| 721 |
+
if (canvas) {
|
| 722 |
+
canvas.dispose();
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
const container = document.getElementById('notes-canvas-container');
|
| 726 |
+
const maxWidth = container.clientWidth;
|
| 727 |
+
const maxHeight = container.clientHeight;
|
| 728 |
+
|
| 729 |
+
// Create canvas with a blank white background (separate revision notes)
|
| 730 |
+
canvas = new fabric.Canvas('notes-canvas', {
|
| 731 |
+
isDrawingMode: true,
|
| 732 |
+
selection: false,
|
| 733 |
+
preserveObjectStacking: true,
|
| 734 |
+
width: maxWidth,
|
| 735 |
+
height: maxHeight,
|
| 736 |
+
backgroundColor: '#ffffff' // White background for handwritten notes
|
| 737 |
+
});
|
| 738 |
+
|
| 739 |
+
// Set initial brush
|
| 740 |
+
setTool('pencil');
|
| 741 |
+
|
| 742 |
+
// Save state after each modification
|
| 743 |
+
canvas.on('object:added', saveCanvasState);
|
| 744 |
+
canvas.on('object:modified', saveCanvasState);
|
| 745 |
+
canvas.on('object:removed', saveCanvasState);
|
| 746 |
+
|
| 747 |
+
// Palm rejection
|
| 748 |
+
canvas.on('mouse:down', function(opt) {
|
| 749 |
+
if (isPenOnly && opt.e.pointerType !== 'pen') {
|
| 750 |
+
canvas.isDrawingMode = false;
|
| 751 |
+
}
|
| 752 |
+
});
|
| 753 |
+
|
| 754 |
+
canvas.on('mouse:up', function() {
|
| 755 |
+
if (isPenOnly && canvas.isDrawingMode === false) {
|
| 756 |
+
const tool = document.querySelector('.notes-toolbar .btn.active')?.id;
|
| 757 |
+
if (tool === 'tool-pencil' || tool === 'tool-highlighter') {
|
| 758 |
+
canvas.isDrawingMode = true;
|
| 759 |
+
}
|
| 760 |
+
}
|
| 761 |
+
});
|
| 762 |
+
|
| 763 |
+
// Keyboard shortcuts
|
| 764 |
+
document.addEventListener('keydown', handleCanvasKeyboard);
|
| 765 |
+
|
| 766 |
+
// Load existing note if present
|
| 767 |
+
if (existingNoteUrl) {
|
| 768 |
+
fabric.Image.fromURL('/processed/' + existingNoteUrl, function(noteImg) {
|
| 769 |
+
// Scale to fit while maintaining aspect ratio
|
| 770 |
+
const scale = Math.min(maxWidth / noteImg.width, maxHeight / noteImg.height, 1);
|
| 771 |
+
noteImg.scale(scale);
|
| 772 |
+
noteImg.set({
|
| 773 |
+
left: (maxWidth - noteImg.width * scale) / 2,
|
| 774 |
+
top: (maxHeight - noteImg.height * scale) / 2,
|
| 775 |
+
selectable: true,
|
| 776 |
+
evented: true
|
| 777 |
+
});
|
| 778 |
+
canvas.add(noteImg);
|
| 779 |
+
canvas.renderAll();
|
| 780 |
+
saveCanvasState();
|
| 781 |
+
}, { crossOrigin: 'anonymous' });
|
| 782 |
+
} else {
|
| 783 |
+
canvas.renderAll();
|
| 784 |
+
saveCanvasState();
|
| 785 |
+
}
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
function handleCanvasKeyboard(e) {
|
| 789 |
+
if (!document.getElementById('notesModal').classList.contains('show')) return;
|
| 790 |
+
|
| 791 |
+
// Ctrl+Z: Undo
|
| 792 |
+
if (e.ctrlKey && e.key === 'z') {
|
| 793 |
+
e.preventDefault();
|
| 794 |
+
undoCanvas();
|
| 795 |
+
return;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
// Ctrl+Y: Redo
|
| 799 |
+
if (e.ctrlKey && e.key === 'y') {
|
| 800 |
+
e.preventDefault();
|
| 801 |
+
redoCanvas();
|
| 802 |
+
return;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
// Delete/Backspace: Delete selected
|
| 806 |
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
| 807 |
+
if (document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
|
| 808 |
+
e.preventDefault();
|
| 809 |
+
deleteSelected();
|
| 810 |
+
}
|
| 811 |
+
return;
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
// Tool shortcuts
|
| 815 |
+
if (e.key === 'p') setTool('pencil');
|
| 816 |
+
if (e.key === 'h') setTool('highlighter');
|
| 817 |
+
if (e.key === 's') setTool('select');
|
| 818 |
+
if (e.key === 't') addText();
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
function saveCanvasState() {
|
| 822 |
+
if (isHistoryAction) return;
|
| 823 |
+
|
| 824 |
+
// Remove any states after current index
|
| 825 |
+
canvasHistory = canvasHistory.slice(0, historyIndex + 1);
|
| 826 |
+
|
| 827 |
+
// Save current state
|
| 828 |
+
const state = canvas.toJSON(['selectable', 'evented']);
|
| 829 |
+
canvasHistory.push(state);
|
| 830 |
+
historyIndex++;
|
| 831 |
+
|
| 832 |
+
// Limit history size
|
| 833 |
+
if (canvasHistory.length > 50) {
|
| 834 |
+
canvasHistory.shift();
|
| 835 |
+
historyIndex--;
|
| 836 |
+
}
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
function undoCanvas() {
|
| 840 |
+
if (historyIndex <= 0) return;
|
| 841 |
+
|
| 842 |
+
isHistoryAction = true;
|
| 843 |
+
historyIndex--;
|
| 844 |
+
|
| 845 |
+
const bg = canvas.backgroundImage;
|
| 846 |
+
canvas.loadFromJSON(canvasHistory[historyIndex], function() {
|
| 847 |
+
canvas.setBackgroundImage(bg, canvas.renderAll.bind(canvas));
|
| 848 |
+
isHistoryAction = false;
|
| 849 |
+
});
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
function redoCanvas() {
|
| 853 |
+
if (historyIndex >= canvasHistory.length - 1) return;
|
| 854 |
+
|
| 855 |
+
isHistoryAction = true;
|
| 856 |
+
historyIndex++;
|
| 857 |
+
|
| 858 |
+
const bg = canvas.backgroundImage;
|
| 859 |
+
canvas.loadFromJSON(canvasHistory[historyIndex], function() {
|
| 860 |
+
canvas.setBackgroundImage(bg, canvas.renderAll.bind(canvas));
|
| 861 |
+
isHistoryAction = false;
|
| 862 |
+
});
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
function setTool(tool) {
|
| 866 |
+
if (!canvas) return;
|
| 867 |
+
|
| 868 |
+
document.querySelectorAll('.notes-toolbar .btn').forEach(b => b.classList.remove('active'));
|
| 869 |
+
|
| 870 |
+
if (tool === 'pencil') {
|
| 871 |
+
document.getElementById('tool-pencil').classList.add('active');
|
| 872 |
+
canvas.isDrawingMode = true;
|
| 873 |
+
canvas.selection = false;
|
| 874 |
+
canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
|
| 875 |
+
canvas.freeDrawingBrush.color = penColor;
|
| 876 |
+
canvas.freeDrawingBrush.width = parseInt(penWidth, 10);
|
| 877 |
+
} else if (tool === 'highlighter') {
|
| 878 |
+
document.getElementById('tool-highlighter').classList.add('active');
|
| 879 |
+
canvas.isDrawingMode = true;
|
| 880 |
+
canvas.selection = false;
|
| 881 |
+
canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
|
| 882 |
+
// Highlighter: larger, semi-transparent
|
| 883 |
+
canvas.freeDrawingBrush.color = hexToRgba(penColor, 0.4);
|
| 884 |
+
canvas.freeDrawingBrush.width = parseInt(penWidth, 10) * 4;
|
| 885 |
+
} else if (tool === 'select') {
|
| 886 |
+
document.getElementById('tool-select').classList.add('active');
|
| 887 |
+
canvas.isDrawingMode = false;
|
| 888 |
+
canvas.selection = true;
|
| 889 |
+
// Make all objects selectable
|
| 890 |
+
canvas.getObjects().forEach(obj => {
|
| 891 |
+
obj.selectable = true;
|
| 892 |
+
obj.evented = true;
|
| 893 |
+
});
|
| 894 |
+
}
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
function hexToRgba(hex, alpha) {
|
| 898 |
+
const r = parseInt(hex.slice(1, 3), 16);
|
| 899 |
+
const g = parseInt(hex.slice(3, 5), 16);
|
| 900 |
+
const b = parseInt(hex.slice(5, 7), 16);
|
| 901 |
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
function setColor(color) {
|
| 905 |
+
penColor = color;
|
| 906 |
+
|
| 907 |
+
document.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
|
| 908 |
+
document.querySelector(`.color-swatch[data-color="${color}"]`)?.classList.add('active');
|
| 909 |
+
|
| 910 |
+
if (canvas && canvas.freeDrawingBrush) {
|
| 911 |
+
const activeTool = document.querySelector('.notes-toolbar .btn.active')?.id;
|
| 912 |
+
if (activeTool === 'tool-highlighter') {
|
| 913 |
+
canvas.freeDrawingBrush.color = hexToRgba(color, 0.4);
|
| 914 |
+
} else {
|
| 915 |
+
canvas.freeDrawingBrush.color = color;
|
| 916 |
+
}
|
| 917 |
+
}
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
function setBrushSize(size) {
|
| 921 |
+
penWidth = size;
|
| 922 |
+
document.getElementById('brush-size-val').textContent = size;
|
| 923 |
+
|
| 924 |
+
if (canvas && canvas.freeDrawingBrush) {
|
| 925 |
+
const activeTool = document.querySelector('.notes-toolbar .btn.active')?.id;
|
| 926 |
+
if (activeTool === 'tool-highlighter') {
|
| 927 |
+
canvas.freeDrawingBrush.width = parseInt(size, 10) * 4;
|
| 928 |
+
} else {
|
| 929 |
+
canvas.freeDrawingBrush.width = parseInt(size, 10);
|
| 930 |
+
}
|
| 931 |
+
}
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
function deleteSelected() {
|
| 935 |
+
if (!canvas) return;
|
| 936 |
+
const active = canvas.getActiveObjects();
|
| 937 |
+
if (active.length > 0) {
|
| 938 |
+
active.forEach(obj => canvas.remove(obj));
|
| 939 |
+
canvas.discardActiveObject();
|
| 940 |
+
canvas.renderAll();
|
| 941 |
+
}
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
function clearCanvas() {
|
| 945 |
+
if (!canvas) return;
|
| 946 |
+
if (confirm("Clear all notes? This cannot be undone.")) {
|
| 947 |
+
canvas.clear();
|
| 948 |
+
canvas.backgroundColor = '#ffffff';
|
| 949 |
+
canvas.renderAll();
|
| 950 |
+
saveCanvasState();
|
| 951 |
+
}
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
function addShape(shape) {
|
| 955 |
+
if (!canvas) return;
|
| 956 |
+
setTool('select');
|
| 957 |
+
|
| 958 |
+
let obj;
|
| 959 |
+
const centerX = canvas.width / 2 - 50;
|
| 960 |
+
const centerY = canvas.height / 2 - 50;
|
| 961 |
+
|
| 962 |
+
if (shape === 'rect') {
|
| 963 |
+
obj = new fabric.Rect({
|
| 964 |
+
left: centerX, top: centerY,
|
| 965 |
+
fill: 'transparent',
|
| 966 |
+
stroke: penColor,
|
| 967 |
+
strokeWidth: parseInt(penWidth, 10),
|
| 968 |
+
width: 100, height: 60
|
| 969 |
+
});
|
| 970 |
+
} else if (shape === 'circle') {
|
| 971 |
+
obj = new fabric.Circle({
|
| 972 |
+
left: centerX, top: centerY,
|
| 973 |
+
fill: 'transparent',
|
| 974 |
+
stroke: penColor,
|
| 975 |
+
strokeWidth: parseInt(penWidth, 10),
|
| 976 |
+
radius: 40
|
| 977 |
+
});
|
| 978 |
+
} else if (shape === 'arrow') {
|
| 979 |
+
// Create an arrow using a line and triangle
|
| 980 |
+
const line = new fabric.Line([centerX, centerY + 50, centerX + 80, centerY], {
|
| 981 |
+
stroke: penColor,
|
| 982 |
+
strokeWidth: parseInt(penWidth, 10),
|
| 983 |
+
selectable: false
|
| 984 |
+
});
|
| 985 |
+
const triangle = new fabric.Triangle({
|
| 986 |
+
left: centerX + 80, top: centerY - 8,
|
| 987 |
+
fill: penColor,
|
| 988 |
+
width: 16, height: 16,
|
| 989 |
+
angle: 45,
|
| 990 |
+
selectable: false
|
| 991 |
+
});
|
| 992 |
+
obj = new fabric.Group([line, triangle], {
|
| 993 |
+
left: centerX, top: centerY
|
| 994 |
+
});
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
if (obj) {
|
| 998 |
+
canvas.add(obj);
|
| 999 |
+
canvas.setActiveObject(obj);
|
| 1000 |
+
}
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
function addText() {
|
| 1004 |
+
if (!canvas) return;
|
| 1005 |
+
setTool('select');
|
| 1006 |
+
|
| 1007 |
+
const text = new fabric.IText('Type here...', {
|
| 1008 |
+
left: canvas.width / 2 - 50,
|
| 1009 |
+
top: canvas.height / 2 - 10,
|
| 1010 |
+
fontFamily: 'Arial, sans-serif',
|
| 1011 |
+
fill: penColor,
|
| 1012 |
+
fontSize: 18 + (parseInt(penWidth, 10) * 2)
|
| 1013 |
+
});
|
| 1014 |
+
|
| 1015 |
+
canvas.add(text);
|
| 1016 |
+
canvas.setActiveObject(text);
|
| 1017 |
+
text.selectAll();
|
| 1018 |
+
text.enterEditing();
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
function togglePenOnly() {
|
| 1022 |
+
isPenOnly = document.getElementById('pen-only-mode').checked;
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
async function toggleNoteInPdf(imageId, include) {
|
| 1026 |
+
try {
|
| 1027 |
+
const response = await fetch('/toggle_note_in_pdf', {
|
| 1028 |
+
method: 'POST',
|
| 1029 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1030 |
+
body: JSON.stringify({ image_id: imageId, include: include })
|
| 1031 |
+
});
|
| 1032 |
+
const result = await response.json();
|
| 1033 |
+
if (!result.success) {
|
| 1034 |
+
showStatus('Failed to update setting: ' + result.error, 'danger');
|
| 1035 |
+
}
|
| 1036 |
+
} catch (e) {
|
| 1037 |
+
showStatus('Error: ' + e.message, 'danger');
|
| 1038 |
+
}
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
async function deleteNote(imageId) {
|
| 1042 |
+
if (!confirm('Delete this note? This cannot be undone.')) return;
|
| 1043 |
+
|
| 1044 |
+
try {
|
| 1045 |
+
const response = await fetch('/delete_note', {
|
| 1046 |
+
method: 'POST',
|
| 1047 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1048 |
+
body: JSON.stringify({ image_id: imageId })
|
| 1049 |
+
});
|
| 1050 |
+
const result = await response.json();
|
| 1051 |
+
if (result.success) {
|
| 1052 |
+
showStatus('Note deleted', 'success');
|
| 1053 |
+
location.reload();
|
| 1054 |
+
} else {
|
| 1055 |
+
showStatus('Failed to delete note: ' + result.error, 'danger');
|
| 1056 |
+
}
|
| 1057 |
+
} catch (e) {
|
| 1058 |
+
showStatus('Error: ' + e.message, 'danger');
|
| 1059 |
+
}
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
async function saveNotes() {
|
| 1063 |
+
if (!canvas || !currentNoteImageId) return;
|
| 1064 |
+
|
| 1065 |
+
// Export the entire canvas with white background
|
| 1066 |
+
const dataUrl = canvas.toDataURL({
|
| 1067 |
+
format: 'png',
|
| 1068 |
+
multiplier: 2 // Higher quality
|
| 1069 |
+
});
|
| 1070 |
+
|
| 1071 |
+
// Convert data URL to blob
|
| 1072 |
+
const response = await fetch(dataUrl);
|
| 1073 |
+
const blob = await response.blob();
|
| 1074 |
+
|
| 1075 |
+
const formData = new FormData();
|
| 1076 |
+
formData.append('image', blob, 'note.png');
|
| 1077 |
+
formData.append('image_id', currentNoteImageId);
|
| 1078 |
+
formData.append('session_id', sessionId);
|
| 1079 |
+
|
| 1080 |
+
try {
|
| 1081 |
+
showStatus('Saving notes...', 'info');
|
| 1082 |
+
const res = await fetch('/save_note_image', {
|
| 1083 |
+
method: 'POST',
|
| 1084 |
+
body: formData
|
| 1085 |
+
});
|
| 1086 |
+
const result = await res.json();
|
| 1087 |
+
|
| 1088 |
+
if (result.success) {
|
| 1089 |
+
showStatus('Notes saved!', 'success');
|
| 1090 |
+
|
| 1091 |
+
// Close modal
|
| 1092 |
+
const modal = bootstrap.Modal.getInstance(document.getElementById('notesModal'));
|
| 1093 |
+
modal.hide();
|
| 1094 |
+
|
| 1095 |
+
// Reload page to show updated note
|
| 1096 |
+
location.reload();
|
| 1097 |
+
} else {
|
| 1098 |
+
showStatus('Error saving notes: ' + result.error, 'danger');
|
| 1099 |
+
}
|
| 1100 |
+
} catch (e) {
|
| 1101 |
+
showStatus('Error: ' + e.message, 'danger');
|
| 1102 |
+
}
|
| 1103 |
+
}
|
| 1104 |
|
| 1105 |
function saveSettings() {
|
| 1106 |
// Session-specific settings
|
utils.py
CHANGED
|
@@ -157,7 +157,7 @@ def create_a4_pdf_from_images(image_info, base_folder, output_filename, images_p
|
|
| 157 |
# 3. Position and paste image below text
|
| 158 |
if img:
|
| 159 |
image_y_start = text_y_start + total_text_height + 20
|
| 160 |
-
|
| 161 |
# Define target dimensions for the image
|
| 162 |
if practice_mode == 'portrait_2_spacious':
|
| 163 |
target_w = (page_width // 2) - 250
|
|
@@ -171,7 +171,19 @@ def create_a4_pdf_from_images(image_info, base_folder, output_filename, images_p
|
|
| 171 |
target_w = cell_width - 40
|
| 172 |
available_h = cell_height - (total_text_height + 40)
|
| 173 |
target_h = available_h
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
# Calculate new dimensions while maintaining aspect ratio
|
| 176 |
img_ratio = img.width / img.height
|
| 177 |
target_ratio = target_w / target_h
|
|
@@ -194,7 +206,7 @@ def create_a4_pdf_from_images(image_info, base_folder, output_filename, images_p
|
|
| 194 |
new_w, new_h = scaled_w, scaled_h
|
| 195 |
|
| 196 |
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| 197 |
-
|
| 198 |
paste_x = cell_x + 20
|
| 199 |
if is_practice_mode and practice_mode != 'portrait_2_spacious':
|
| 200 |
paste_x = 200
|
|
@@ -208,6 +220,35 @@ def create_a4_pdf_from_images(image_info, base_folder, output_filename, images_p
|
|
| 208 |
x1, y1 = x0 + new_w, y0 + new_h
|
| 209 |
draw_dashed_rectangle(draw, [x0, y0, x1, y1], fill="gray", width=3, dash_length=20, gap_length=15)
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
except Exception as e:
|
| 212 |
print(f"Error processing image for PDF: {e}")
|
| 213 |
|
|
|
|
| 157 |
# 3. Position and paste image below text
|
| 158 |
if img:
|
| 159 |
image_y_start = text_y_start + total_text_height + 20
|
| 160 |
+
|
| 161 |
# Define target dimensions for the image
|
| 162 |
if practice_mode == 'portrait_2_spacious':
|
| 163 |
target_w = (page_width // 2) - 250
|
|
|
|
| 171 |
target_w = cell_width - 40
|
| 172 |
available_h = cell_height - (total_text_height + 40)
|
| 173 |
target_h = available_h
|
| 174 |
+
|
| 175 |
+
# If there's a note, split the available height between question and note
|
| 176 |
+
note_img = None
|
| 177 |
+
if info.get('note_filename'):
|
| 178 |
+
try:
|
| 179 |
+
note_path = os.path.join(base_folder, info['note_filename'])
|
| 180 |
+
if os.path.exists(note_path):
|
| 181 |
+
note_img = Image.open(note_path).convert("RGB")
|
| 182 |
+
# Split available height: 60% for question, 40% for note
|
| 183 |
+
target_h = int(available_h * 0.6)
|
| 184 |
+
except Exception as e:
|
| 185 |
+
print(f"Error loading note: {e}")
|
| 186 |
+
|
| 187 |
# Calculate new dimensions while maintaining aspect ratio
|
| 188 |
img_ratio = img.width / img.height
|
| 189 |
target_ratio = target_w / target_h
|
|
|
|
| 206 |
new_w, new_h = scaled_w, scaled_h
|
| 207 |
|
| 208 |
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| 209 |
+
|
| 210 |
paste_x = cell_x + 20
|
| 211 |
if is_practice_mode and practice_mode != 'portrait_2_spacious':
|
| 212 |
paste_x = 200
|
|
|
|
| 220 |
x1, y1 = x0 + new_w, y0 + new_h
|
| 221 |
draw_dashed_rectangle(draw, [x0, y0, x1, y1], fill="gray", width=3, dash_length=20, gap_length=15)
|
| 222 |
|
| 223 |
+
# Paste the note image below the question image if present
|
| 224 |
+
if note_img:
|
| 225 |
+
try:
|
| 226 |
+
note_y_start = image_y_start + new_h + 10 # 10px gap between question and note
|
| 227 |
+
note_available_h = cell_height - (note_y_start - cell_y) - 20
|
| 228 |
+
note_target_w = target_w
|
| 229 |
+
note_target_h = note_available_h
|
| 230 |
+
|
| 231 |
+
# Calculate note dimensions while maintaining aspect ratio
|
| 232 |
+
note_ratio = note_img.width / note_img.height
|
| 233 |
+
note_target_ratio = note_target_w / max(note_target_h, 1)
|
| 234 |
+
|
| 235 |
+
if note_ratio > note_target_ratio:
|
| 236 |
+
note_new_w = int(note_target_w)
|
| 237 |
+
note_new_h = int(note_new_w / note_ratio)
|
| 238 |
+
else:
|
| 239 |
+
note_new_h = int(note_target_h)
|
| 240 |
+
note_new_w = int(note_new_h * note_ratio)
|
| 241 |
+
|
| 242 |
+
if note_new_w > 0 and note_new_h > 0:
|
| 243 |
+
note_img = note_img.resize((note_new_w, note_new_h), Image.Resampling.LANCZOS)
|
| 244 |
+
note_paste_position = (paste_x, note_y_start)
|
| 245 |
+
page.paste(note_img, note_paste_position)
|
| 246 |
+
|
| 247 |
+
# Draw note label
|
| 248 |
+
draw.text((paste_x, note_y_start - 15), "Notes:", fill="blue", font=font_small)
|
| 249 |
+
except Exception as e:
|
| 250 |
+
print(f"Error pasting note image: {e}")
|
| 251 |
+
|
| 252 |
except Exception as e:
|
| 253 |
print(f"Error processing image for PDF: {e}")
|
| 254 |
|