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 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("SELECT q.*, i.filename, i.processed_filename FROM questions q JOIN images i ON q.image_id = i.id WHERE q.session_id = ? ORDER BY i.id", (sid,)).fetchall()]
 
 
 
 
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("SELECT q.*, i.filename, i.processed_filename FROM questions q JOIN images i ON q.image_id = i.id WHERE q.session_id = ? ORDER BY i.id", (sid,)).fetchall()]
 
 
 
 
 
 
 
 
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