t Claude Opus 4.6 commited on
Commit
e311892
·
1 Parent(s): 6d16370

feat: refactor revision notes to store JSON instead of rasterized images

Browse files

- Extract revision notes modal to separate partial (_revision_notes.html)
- Save Fabric.js canvas as JSON to database instead of PNG files
- Add /save_note_json and /get_note_json API endpoints
- Add note_json column migration to images table
- Fix stylus mode palm rejection - remove accidental paths from finger touch
- Add two_page_crop user setting with database migration
- Update serve_processed_file to authorize both processed_filename and note_filename
- Add null checks in setupEventListeners to prevent JS errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

database.py CHANGED
@@ -339,6 +339,11 @@ def setup_database():
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
 
@@ -380,6 +385,11 @@ def setup_database():
380
  except sqlite3.OperationalError:
381
  cursor.execute("ALTER TABLE neetprep_bookmarks ADD COLUMN question_type TEXT DEFAULT 'neetprep'")
382
 
 
 
 
 
 
383
  conn.commit()
384
  conn.close()
385
 
 
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 note_json FROM images LIMIT 1")
344
+ except sqlite3.OperationalError:
345
+ cursor.execute("ALTER TABLE images ADD COLUMN note_json TEXT")
346
+
347
  try:
348
  cursor.execute("SELECT session_type FROM sessions LIMIT 1")
349
 
 
385
  except sqlite3.OperationalError:
386
  cursor.execute("ALTER TABLE neetprep_bookmarks ADD COLUMN question_type TEXT DEFAULT 'neetprep'")
387
 
388
+ try:
389
+ cursor.execute("SELECT two_page_crop FROM users LIMIT 1")
390
+ except sqlite3.OperationalError:
391
+ cursor.execute("ALTER TABLE users ADD COLUMN two_page_crop INTEGER DEFAULT 0")
392
+
393
  conn.commit()
394
  conn.close()
395
 
image_routes.py CHANGED
@@ -125,7 +125,7 @@ def delete_note():
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
 
@@ -133,4 +133,67 @@ def delete_note():
133
 
134
  except Exception as e:
135
  current_app.logger.error(f"Error deleting note: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  return jsonify({'error': str(e)}), 500
 
125
  if os.path.exists(note_path):
126
  os.remove(note_path)
127
 
128
+ conn.execute("UPDATE images SET note_filename = NULL, note_json = NULL WHERE id = ?", (image_id,))
129
  conn.commit()
130
  conn.close()
131
 
 
133
 
134
  except Exception as e:
135
  current_app.logger.error(f"Error deleting note: {e}")
136
+ return jsonify({'error': str(e)}), 500
137
+
138
+
139
+ @image_bp.route('/save_note_json', methods=['POST'])
140
+ @login_required
141
+ def save_note_json():
142
+ """Save revision notes as JSON (no rasterization)."""
143
+ try:
144
+ data = request.json
145
+ image_id = data.get('image_id')
146
+ session_id = data.get('session_id')
147
+ json_data = data.get('json_data')
148
+
149
+ if not image_id or not session_id or not json_data:
150
+ return jsonify({'error': 'Missing required fields'}), 400
151
+
152
+ # Validate ownership
153
+ conn = get_db_connection()
154
+ img = conn.execute("""
155
+ SELECT i.id, s.user_id FROM images i
156
+ JOIN sessions s ON i.session_id = s.id WHERE i.id = ?
157
+ """, (image_id,)).fetchone()
158
+
159
+ if not img or img['user_id'] != current_user.id:
160
+ conn.close()
161
+ return jsonify({'error': 'Unauthorized'}), 403
162
+
163
+ # Save JSON to database
164
+ conn.execute("UPDATE images SET note_json = ? WHERE id = ?", (json_data, image_id))
165
+ conn.commit()
166
+ conn.close()
167
+
168
+ return jsonify({'success': True})
169
+
170
+ except Exception as e:
171
+ current_app.logger.error(f"Error saving note JSON: {e}")
172
+ return jsonify({'error': str(e)}), 500
173
+
174
+
175
+ @image_bp.route('/get_note_json/<int:image_id>')
176
+ @login_required
177
+ def get_note_json(image_id):
178
+ """Get revision notes as JSON."""
179
+ try:
180
+ conn = get_db_connection()
181
+ img = conn.execute("""
182
+ SELECT i.note_json, s.user_id FROM images i
183
+ JOIN sessions s ON i.session_id = s.id WHERE i.id = ?
184
+ """, (image_id,)).fetchone()
185
+
186
+ if not img or img['user_id'] != current_user.id:
187
+ conn.close()
188
+ return jsonify({'error': 'Unauthorized'}), 403
189
+
190
+ conn.close()
191
+
192
+ if img['note_json']:
193
+ return jsonify({'success': True, 'json_data': img['note_json']})
194
+ else:
195
+ return jsonify({'success': False, 'error': 'No note found'}), 404
196
+
197
+ except Exception as e:
198
+ current_app.logger.error(f"Error getting note JSON: {e}")
199
  return jsonify({'error': str(e)}), 500
routes/processing.py CHANGED
@@ -21,12 +21,56 @@ def crop_interface_v2(session_id, image_index):
21
  session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone()
22
  if not session_owner or session_owner['user_id'] != current_user.id:
23
  conn.close(); return "Unauthorized", 403
24
- image_info = conn.execute("SELECT * FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, image_index)).fetchone()
25
- if not image_info: conn.close(); return "Original page/image not found.", 404
26
- total_pages = conn.execute("SELECT COUNT(*) FROM images WHERE session_id = ? AND image_type = 'original'", (session_id,)).fetchone()[0]
27
- all_pages = [{'image_index': row['image_index'], 'filename': row['filename']} for row in conn.execute("SELECT image_index, filename FROM images WHERE session_id = ? AND image_type = 'original' ORDER BY image_index ASC", (session_id,)).fetchall()]
28
- conn.close()
29
- return render_template('cropv2.html', session_id=session_id, user_id=current_user.id, image_index=image_index, image_info=image_info, total_pages=total_pages, all_pages=all_pages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  @main_bp.route(ROUTE_PROCESS_CROP_V2, methods=[METHOD_POST])
32
  @login_required
 
21
  session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone()
22
  if not session_owner or session_owner['user_id'] != current_user.id:
23
  conn.close(); return "Unauthorized", 403
24
+
25
+ # Check if two-page mode is enabled
26
+ two_page_mode = getattr(current_user, 'two_page_crop', 0)
27
+
28
+ if two_page_mode:
29
+ # In two-page mode, image_index represents the pair index (0 = pages 0-1, 1 = pages 2-3, etc.)
30
+ pair_index = image_index
31
+ left_page_index = pair_index * 2
32
+ right_page_index = left_page_index + 1
33
+
34
+ left_image = conn.execute("SELECT * FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, left_page_index)).fetchone()
35
+ right_image = conn.execute("SELECT * FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, right_page_index)).fetchone()
36
+
37
+ if not left_image:
38
+ conn.close(); return "Original page/image not found.", 404
39
+
40
+ total_pages = conn.execute("SELECT COUNT(*) FROM images WHERE session_id = ? AND image_type = 'original'", (session_id,)).fetchone()[0]
41
+ total_pairs = (total_pages + 1) // 2 # Round up for odd number of pages
42
+
43
+ all_pages = [{'image_index': row['image_index'], 'filename': row['filename']} for row in conn.execute("SELECT image_index, filename FROM images WHERE session_id = ? AND image_type = 'original' ORDER BY image_index ASC", (session_id,)).fetchall()]
44
+ conn.close()
45
+
46
+ return render_template('cropv2.html',
47
+ session_id=session_id,
48
+ user_id=current_user.id,
49
+ image_index=pair_index,
50
+ image_info=left_image,
51
+ right_image_info=dict(right_image) if right_image else None,
52
+ total_pages=total_pairs,
53
+ all_pages=all_pages,
54
+ two_page_mode=True,
55
+ left_page_index=left_page_index,
56
+ right_page_index=right_page_index
57
+ )
58
+ else:
59
+ # Standard single-page mode
60
+ image_info = conn.execute("SELECT * FROM images WHERE session_id = ? AND image_index = ? AND image_type = 'original'", (session_id, image_index)).fetchone()
61
+ if not image_info: conn.close(); return "Original page/image not found.", 404
62
+ total_pages = conn.execute("SELECT COUNT(*) FROM images WHERE session_id = ? AND image_type = 'original'", (session_id,)).fetchone()[0]
63
+ all_pages = [{'image_index': row['image_index'], 'filename': row['filename']} for row in conn.execute("SELECT image_index, filename FROM images WHERE session_id = ? AND image_type = 'original' ORDER BY image_index ASC", (session_id,)).fetchall()]
64
+ conn.close()
65
+ return render_template('cropv2.html',
66
+ session_id=session_id,
67
+ user_id=current_user.id,
68
+ image_index=image_index,
69
+ image_info=image_info,
70
+ total_pages=total_pages,
71
+ all_pages=all_pages,
72
+ two_page_mode=False
73
+ )
74
 
75
  @main_bp.route(ROUTE_PROCESS_CROP_V2, methods=[METHOD_POST])
76
  @login_required
routes/serving.py CHANGED
@@ -16,9 +16,10 @@ def serve_tmp_file(filename):
16
  @login_required
17
  def serve_processed_file(filename):
18
  conn = get_db_connection()
 
19
  image_owner = conn.execute(
20
- "SELECT s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.processed_filename = ?",
21
- (filename,)
22
  ).fetchone()
23
  conn.close()
24
 
 
16
  @login_required
17
  def serve_processed_file(filename):
18
  conn = get_db_connection()
19
+ # Check both processed_filename and note_filename columns
20
  image_owner = conn.execute(
21
+ "SELECT s.user_id FROM images i JOIN sessions s ON i.session_id = s.id WHERE i.processed_filename = ? OR i.note_filename = ?",
22
+ (filename, filename)
23
  ).fetchone()
24
  conn.close()
25
 
settings_routes.py CHANGED
@@ -32,6 +32,9 @@ def settings():
32
  # --- Handle Magnifier Toggle ---
33
  magnifier_enabled = 1 if request.form.get('magnifier_enabled') else 0
34
 
 
 
 
35
  # --- Handle Classifier Model Setting ---
36
  classifier_model = request.form.get('classifier_model', 'gemini')
37
  if classifier_model not in ['gemini', 'nova', 'gemma']:
@@ -67,15 +70,16 @@ def settings():
67
 
68
  # --- Update Database ---
69
  conn = get_db_connection()
70
- conn.execute('UPDATE users SET neetprep_enabled = ?, v2_default = ?, magnifier_enabled = ?, dpi = ?, color_rm_dpi = ?, classifier_model = ? WHERE id = ?',
71
- (neetprep_enabled, v2_default, magnifier_enabled, dpi, color_rm_dpi, classifier_model, current_user.id))
72
  conn.commit()
73
  conn.close()
74
-
75
  # --- Update current_user object for the session ---
76
  current_user.neetprep_enabled = neetprep_enabled
77
  current_user.v2_default = v2_default
78
  current_user.magnifier_enabled = magnifier_enabled
 
79
  current_user.dpi = dpi
80
  current_user.color_rm_dpi = color_rm_dpi
81
  current_user.classifier_model = classifier_model
 
32
  # --- Handle Magnifier Toggle ---
33
  magnifier_enabled = 1 if request.form.get('magnifier_enabled') else 0
34
 
35
+ # --- Handle Two-Page Crop Toggle ---
36
+ two_page_crop = 1 if request.form.get('two_page_crop') else 0
37
+
38
  # --- Handle Classifier Model Setting ---
39
  classifier_model = request.form.get('classifier_model', 'gemini')
40
  if classifier_model not in ['gemini', 'nova', 'gemma']:
 
70
 
71
  # --- Update Database ---
72
  conn = get_db_connection()
73
+ conn.execute('UPDATE users SET neetprep_enabled = ?, v2_default = ?, magnifier_enabled = ?, two_page_crop = ?, dpi = ?, color_rm_dpi = ?, classifier_model = ? WHERE id = ?',
74
+ (neetprep_enabled, v2_default, magnifier_enabled, two_page_crop, dpi, color_rm_dpi, classifier_model, current_user.id))
75
  conn.commit()
76
  conn.close()
77
+
78
  # --- Update current_user object for the session ---
79
  current_user.neetprep_enabled = neetprep_enabled
80
  current_user.v2_default = v2_default
81
  current_user.magnifier_enabled = magnifier_enabled
82
+ current_user.two_page_crop = two_page_crop
83
  current_user.dpi = dpi
84
  current_user.color_rm_dpi = color_rm_dpi
85
  current_user.classifier_model = classifier_model
templates/_revision_notes.html ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#
2
+ Revision Notes Modal - Final Fix
3
+ Fixes: Stylus Logic (via Native Pointer Capture), Object Eraser, UX
4
+ #}
5
+
6
+ {# ===== STYLES ===== #}
7
+ <style>
8
+ /* --- Layout --- */
9
+ #notesModal .modal-body {
10
+ padding: 0;
11
+ background-color: #f0f2f5;
12
+ overflow: hidden;
13
+ user-select: none;
14
+ }
15
+
16
+ #notes-canvas-wrapper {
17
+ width: 100%;
18
+ height: 100%;
19
+ background-color: #ffffff;
20
+ background-image: radial-gradient(#ced4da 1px, transparent 1px);
21
+ background-size: 24px 24px;
22
+ /* CRITICAL: Disables browser zooming/scrolling so Pen works */
23
+ touch-action: none;
24
+ cursor: crosshair;
25
+ position: relative;
26
+ }
27
+
28
+ /* --- Toolbar --- */
29
+ .notes-toolbar {
30
+ position: absolute;
31
+ top: 24px;
32
+ left: 50%;
33
+ transform: translateX(-50%);
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 8px;
37
+ padding: 8px 16px;
38
+ border-radius: 100px;
39
+ z-index: 1060;
40
+ background: rgba(33, 37, 41, 0.9);
41
+ backdrop-filter: blur(12px);
42
+ -webkit-backdrop-filter: blur(12px);
43
+ border: 1px solid rgba(255, 255, 255, 0.15);
44
+ box-shadow: 0 10px 30px rgba(0,0,0,0.3);
45
+ }
46
+
47
+ /* --- Buttons --- */
48
+ .tool-btn {
49
+ width: 42px;
50
+ height: 42px;
51
+ border-radius: 50%;
52
+ border: none;
53
+ background: transparent;
54
+ color: rgba(255,255,255,0.6);
55
+ font-size: 1.2rem;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ transition: all 0.2s;
60
+ }
61
+
62
+ .tool-btn:hover {
63
+ background: rgba(255,255,255,0.15);
64
+ color: #fff;
65
+ transform: translateY(-2px);
66
+ }
67
+
68
+ .tool-btn.active {
69
+ background: var(--accent-primary, #0d6efd);
70
+ color: white;
71
+ box-shadow: 0 4px 15px rgba(13, 110, 253, 0.4);
72
+ transform: scale(1.1);
73
+ }
74
+
75
+ /* Stylus Mode Active State */
76
+ #btn-stylus.active {
77
+ background: #198754; /* Green */
78
+ box-shadow: 0 4px 15px rgba(25, 135, 84, 0.4);
79
+ color: white;
80
+ }
81
+
82
+ .sep { width: 1px; height: 24px; background: rgba(255,255,255,0.2); margin: 0 6px; }
83
+
84
+ .color-dot {
85
+ width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent; cursor: pointer;
86
+ }
87
+ .color-dot.active { border-color: #fff; transform: scale(1.2); }
88
+
89
+ /* --- Ref Panel --- */
90
+ #ref-panel {
91
+ position: absolute; bottom: 20px; right: 20px; width: 280px;
92
+ background: #2b3035; border-radius: 12px; padding: 10px;
93
+ z-index: 1050; box-shadow: 0 10px 30px rgba(0,0,0,0.3);
94
+ transition: transform 0.3s ease;
95
+ }
96
+ #ref-panel.collapsed { transform: translateY(150%); }
97
+ #ref-panel img { width: 100%; border-radius: 8px; border: 1px solid #495057; }
98
+
99
+ .status-badge {
100
+ position: absolute; bottom: 20px; left: 20px;
101
+ background: rgba(0,0,0,0.7); color: white;
102
+ padding: 5px 12px; border-radius: 20px;
103
+ font-family: monospace; font-size: 0.85rem; pointer-events: none;
104
+ }
105
+ </style>
106
+
107
+ {# ===== HTML ===== #}
108
+ <div class="modal fade" id="notesModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
109
+ <div class="modal-dialog modal-fullscreen">
110
+ <div class="modal-content bg-dark">
111
+ <div class="modal-body">
112
+
113
+ <!-- Toolbar -->
114
+ <div class="notes-toolbar">
115
+ <button class="tool-btn active" id="btn-pencil" onclick="setTool('pencil')" title="Pencil">
116
+ <i class="fas fa-pencil-alt"></i>
117
+ </button>
118
+ <button class="tool-btn" id="btn-highlighter" onclick="setTool('highlighter')" title="Highlighter">
119
+ <i class="fas fa-highlighter"></i>
120
+ </button>
121
+ <button class="tool-btn" id="btn-eraser" onclick="setTool('eraser')" title="Object Eraser">
122
+ <i class="fas fa-eraser"></i>
123
+ </button>
124
+
125
+ <div class="sep"></div>
126
+
127
+ <div class="d-flex gap-2 mx-1">
128
+ <div class="color-dot active" style="background:#212529" onclick="setColor('#212529', this)"></div>
129
+ <div class="color-dot" style="background:#dc3545" onclick="setColor('#dc3545', this)"></div>
130
+ <div class="color-dot" style="background:#0d6efd" onclick="setColor('#0d6efd', this)"></div>
131
+ </div>
132
+
133
+ <div class="sep"></div>
134
+
135
+ <div class="dropdown">
136
+ <button class="tool-btn" data-bs-toggle="dropdown"><i class="fas fa-shapes"></i></button>
137
+ <ul class="dropdown-menu dropdown-menu-dark">
138
+ <li><button class="dropdown-item" onclick="addShape('rect')">Rectangle</button></li>
139
+ <li><button class="dropdown-item" onclick="addShape('circle')">Circle</button></li>
140
+ <li><button class="dropdown-item" onclick="addShape('arrow')">Arrow</button></li>
141
+ <li><button class="dropdown-item" onclick="addText()">Text</button></li>
142
+ </ul>
143
+ </div>
144
+
145
+ <button class="tool-btn" id="btn-select" onclick="setTool('select')"><i class="fas fa-mouse-pointer"></i></button>
146
+
147
+ <div class="sep"></div>
148
+
149
+ <!-- Stylus Toggle -->
150
+ <button class="tool-btn" id="btn-stylus" onclick="toggleStylus()" title="Stylus Only Mode">
151
+ <i class="fas fa-pen-nib"></i>
152
+ </button>
153
+
154
+ <div class="sep"></div>
155
+
156
+ <button class="tool-btn" onclick="undo()"><i class="fas fa-undo"></i></button>
157
+ <button class="tool-btn text-success" onclick="saveNotes()"><i class="fas fa-check"></i></button>
158
+ <button class="tool-btn text-secondary" data-bs-dismiss="modal"><i class="fas fa-times"></i></button>
159
+ </div>
160
+
161
+ <!-- Canvas Wrapper -->
162
+ <div id="notes-canvas-wrapper">
163
+ <canvas id="notes-canvas"></canvas>
164
+ </div>
165
+
166
+ <!-- Ref Panel -->
167
+ <div id="ref-panel">
168
+ <div class="d-flex justify-content-between align-items-center mb-1">
169
+ <small class="text-white-50">Reference</small>
170
+ <button class="btn btn-sm btn-link text-white-50 p-0" onclick="document.getElementById('ref-panel').classList.add('collapsed')"><i class="fas fa-chevron-down"></i></button>
171
+ </div>
172
+ <img id="notes-ref-img" src="">
173
+ </div>
174
+
175
+ <button class="btn btn-dark rounded-circle shadow position-absolute bottom-0 end-0 m-3" onclick="document.getElementById('ref-panel').classList.remove('collapsed')">
176
+ <i class="fas fa-image"></i>
177
+ </button>
178
+
179
+ <div class="status-badge">
180
+ <span id="debug-pointer">Mode: Touch Draw</span>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ {# ===== JAVASCRIPT ===== #}
188
+ <script>
189
+ let canvas;
190
+ let isStylusMode = false;
191
+ let currentTool = 'pencil';
192
+ let currentColor = '#212529';
193
+ let activeImageId = null;
194
+ let historyStack = [];
195
+
196
+ // NATIVE POINTER TRACKING (The Fix)
197
+ let lastPointerType = 'mouse';
198
+ let isFingerPanning = false; // Track if we're currently panning with finger
199
+
200
+ function openNotesModal(imageId, refUrl) {
201
+ activeImageId = imageId;
202
+ document.getElementById('notes-ref-img').src = refUrl;
203
+ document.getElementById('ref-panel').classList.remove('collapsed');
204
+
205
+ const modal = new bootstrap.Modal(document.getElementById('notesModal'));
206
+ modal.show();
207
+
208
+ // Init Fabric after modal is shown
209
+ document.getElementById('notesModal').addEventListener('shown.bs.modal', () => {
210
+ initFabric();
211
+ }, { once: true });
212
+ }
213
+
214
+ function initFabric() {
215
+ if (canvas) canvas.dispose();
216
+ const wrapper = document.getElementById('notes-canvas-wrapper');
217
+
218
+ // 1. Attach NATIVE Pointer Listener to wrapper
219
+ // This runs before Fabric and tells us exactly what hardware is being used
220
+ wrapper.addEventListener('pointerdown', (e) => {
221
+ lastPointerType = e.pointerType; // 'mouse', 'pen', or 'touch'
222
+ console.log("Hardware Detected:", lastPointerType);
223
+
224
+ // If Eraser mode, we can handle deletion here for better responsiveness
225
+ if (currentTool === 'eraser' && !canvas.isDragging) {
226
+ // Find object under native event coordinates
227
+ // We let Fabric's mouse:down handle it to ensure coordinates are transformed correctly
228
+ }
229
+ }, true);
230
+
231
+ canvas = new fabric.Canvas('notes-canvas', {
232
+ width: wrapper.clientWidth,
233
+ height: wrapper.clientHeight,
234
+ backgroundColor: '#ffffff',
235
+ isDrawingMode: true,
236
+ selection: false,
237
+ preserveObjectStacking: true,
238
+ perPixelTargetFind: true // Critical for Object Eraser precision
239
+ });
240
+
241
+ setupFabricEvents();
242
+ setupZoom();
243
+ setTool('pencil');
244
+
245
+ // Always try to load existing note from server (in case page state is stale)
246
+ loadNoteJson();
247
+
248
+ // Handle Resize
249
+ window.addEventListener('resize', () => {
250
+ canvas.setWidth(wrapper.clientWidth);
251
+ canvas.setHeight(wrapper.clientHeight);
252
+ });
253
+ }
254
+
255
+ async function loadNoteJson() {
256
+ try {
257
+ console.log('Loading note for image:', activeImageId);
258
+ const response = await fetch('/get_note_json/' + activeImageId);
259
+ console.log('Response status:', response.status);
260
+ if (response.ok) {
261
+ const data = await response.json();
262
+ console.log('Response data:', data);
263
+ if (data.success && data.json_data) {
264
+ const jsonData = typeof data.json_data === 'string' ? JSON.parse(data.json_data) : data.json_data;
265
+ console.log('Parsed JSON:', jsonData);
266
+ canvas.loadFromJSON(jsonData, () => {
267
+ canvas.renderAll();
268
+ console.log('Canvas loaded successfully');
269
+ saveState();
270
+ });
271
+ return;
272
+ }
273
+ }
274
+ } catch (e) {
275
+ console.log('Error loading note:', e);
276
+ }
277
+ saveState();
278
+ }
279
+
280
+ function setupFabricEvents() {
281
+ // Intercept path creation - in stylus mode, only keep paths from pen
282
+ canvas.on('path:created', function(opt) {
283
+ if (isStylusMode && lastPointerType !== 'pen') {
284
+ // Remove paths created by finger/touch in stylus mode
285
+ canvas.remove(opt.path);
286
+ canvas.requestRenderAll();
287
+ return;
288
+ }
289
+ if (isFingerPanning) {
290
+ // Also remove if we're still in finger panning state
291
+ canvas.remove(opt.path);
292
+ canvas.requestRenderAll();
293
+ return;
294
+ }
295
+ });
296
+
297
+ canvas.on('mouse:down', function(opt) {
298
+ const evt = opt.e;
299
+
300
+ // --- STYLUS LOGIC ---
301
+ if (isStylusMode) {
302
+ // We ignore Fabric's event type and check our global 'lastPointerType'
303
+ if (lastPointerType === 'touch') {
304
+ // It is a finger -> PAN ONLY
305
+ isFingerPanning = true;
306
+ this.isDrawingMode = false;
307
+ this.selection = false;
308
+ this.isDragging = true;
309
+ this.lastPosX = evt.clientX || (evt.touches && evt.touches[0]?.clientX) || 0;
310
+ this.lastPosY = evt.clientY || (evt.touches && evt.touches[0]?.clientY) || 0;
311
+
312
+ // Cancel any in-progress drawing
313
+ if (this._isCurrentlyDrawing) {
314
+ this._isCurrentlyDrawing = false;
315
+ }
316
+ return; // Stop here
317
+ }
318
+
319
+ if (lastPointerType === 'pen') {
320
+ // It is a pen -> DRAW
321
+ isFingerPanning = false;
322
+ if (currentTool === 'pencil' || currentTool === 'highlighter') {
323
+ this.isDrawingMode = true;
324
+ }
325
+ }
326
+ }
327
+ // --- TOUCH MODE (Default) ---
328
+ else {
329
+ isFingerPanning = false;
330
+ // Multi-touch always pans
331
+ if (evt.touches && evt.touches.length > 1) {
332
+ this.isDrawingMode = false;
333
+ this.isDragging = true;
334
+ return;
335
+ }
336
+ }
337
+
338
+ // --- ERASER LOGIC (Object) ---
339
+ if (currentTool === 'eraser') {
340
+ this.isDrawingMode = false;
341
+ this.isErasing = true;
342
+ deleteObjectUnderPointer(opt.e);
343
+ }
344
+ });
345
+
346
+ canvas.on('mouse:move', function(opt) {
347
+ if (this.isDragging) {
348
+ const e = opt.e;
349
+ const vpt = this.viewportTransform;
350
+ const clientX = e.clientX || (e.touches && e.touches[0]?.clientX) || this.lastPosX;
351
+ const clientY = e.clientY || (e.touches && e.touches[0]?.clientY) || this.lastPosY;
352
+ vpt[4] += clientX - this.lastPosX;
353
+ vpt[5] += clientY - this.lastPosY;
354
+ this.requestRenderAll();
355
+ this.lastPosX = clientX;
356
+ this.lastPosY = clientY;
357
+ }
358
+ if (this.isErasing) {
359
+ deleteObjectUnderPointer(opt.e);
360
+ }
361
+ });
362
+
363
+ canvas.on('mouse:up', function() {
364
+ const wasPanning = isFingerPanning;
365
+ this.isDragging = false;
366
+ this.isErasing = false;
367
+ isFingerPanning = false;
368
+
369
+ // Restore drawing mode if needed (only if not just finished finger panning)
370
+ if ((currentTool === 'pencil' || currentTool === 'highlighter') && !wasPanning) {
371
+ if (!isStylusMode || lastPointerType === 'pen') {
372
+ canvas.isDrawingMode = true;
373
+ }
374
+ }
375
+
376
+ // In stylus mode, keep drawing disabled until next pen touch
377
+ if (isStylusMode && lastPointerType === 'touch') {
378
+ canvas.isDrawingMode = false;
379
+ }
380
+
381
+ saveState();
382
+ });
383
+ }
384
+
385
+ function deleteObjectUnderPointer(e) {
386
+ // findTarget requires pointer event coordinates
387
+ const target = canvas.findTarget(e, false);
388
+ if (target) {
389
+ canvas.remove(target);
390
+ canvas.requestRenderAll();
391
+ }
392
+ }
393
+
394
+ function setTool(name) {
395
+ currentTool = name;
396
+ document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
397
+ document.getElementById(`btn-${name}`)?.classList.add('active');
398
+
399
+ // Reset
400
+ canvas.isDrawingMode = false;
401
+ canvas.selection = false;
402
+ canvas.defaultCursor = 'default';
403
+ canvas.getObjects().forEach(o => { o.selectable = false; o.evented = false; });
404
+
405
+ if (name === 'pencil') {
406
+ // In stylus mode, keep drawing disabled until pen touch
407
+ // In touch mode, enable immediately
408
+ canvas.isDrawingMode = !isStylusMode;
409
+ canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
410
+ canvas.freeDrawingBrush.color = currentColor;
411
+ canvas.freeDrawingBrush.width = 3;
412
+ }
413
+ else if (name === 'highlighter') {
414
+ // In stylus mode, keep drawing disabled until pen touch
415
+ canvas.isDrawingMode = !isStylusMode;
416
+ canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
417
+ const c = hexToRgb(currentColor);
418
+ canvas.freeDrawingBrush.color = `rgba(${c.r},${c.g},${c.b},0.3)`;
419
+ canvas.freeDrawingBrush.width = 20;
420
+ }
421
+ else if (name === 'eraser') {
422
+ canvas.defaultCursor = 'crosshair';
423
+ // Objects must be evented to be found by findTarget
424
+ canvas.getObjects().forEach(o => o.evented = true);
425
+ }
426
+ else if (name === 'select') {
427
+ canvas.selection = true;
428
+ canvas.defaultCursor = 'move';
429
+ canvas.getObjects().forEach(o => { o.selectable = true; o.evented = true; });
430
+ }
431
+ }
432
+
433
+ function toggleStylus() {
434
+ isStylusMode = !isStylusMode;
435
+ const btn = document.getElementById('btn-stylus');
436
+ const debug = document.getElementById('debug-pointer');
437
+
438
+ if (isStylusMode) {
439
+ btn.classList.add('active');
440
+ debug.textContent = "Mode: Stylus Only (Fingers Pan)";
441
+ debug.style.color = "#20c997";
442
+ // Disable drawing mode - will be enabled on pen touch
443
+ if (currentTool === 'pencil' || currentTool === 'highlighter') {
444
+ canvas.isDrawingMode = false;
445
+ }
446
+ } else {
447
+ btn.classList.remove('active');
448
+ debug.textContent = "Mode: Touch Draw";
449
+ debug.style.color = "white";
450
+ // Re-enable drawing mode for touch
451
+ if (currentTool === 'pencil' || currentTool === 'highlighter') {
452
+ canvas.isDrawingMode = true;
453
+ }
454
+ }
455
+ }
456
+
457
+ function addShape(type) {
458
+ setTool('select');
459
+ const center = canvas.getVpCenter();
460
+ const opts = { left:center.x, top:center.y, fill:'transparent', stroke:currentColor, strokeWidth:3, originX:'center', originY:'center' };
461
+ let obj;
462
+ if(type==='rect') obj = new fabric.Rect({...opts, width:100, height:80});
463
+ if(type==='circle') obj = new fabric.Circle({...opts, radius:40});
464
+ if(type==='arrow') obj = new fabric.Path('M 0 0 L 100 0 M 90 -10 L 100 0 L 90 10', {...opts, fill:null});
465
+
466
+ if(obj) { canvas.add(obj); canvas.setActiveObject(obj); saveState(); }
467
+ }
468
+
469
+ function addText() {
470
+ setTool('select');
471
+ const center = canvas.getVpCenter();
472
+ const t = new fabric.IText('Text', { left:center.x, top:center.y, fontSize:24, fill:currentColor });
473
+ canvas.add(t); canvas.setActiveObject(t); t.enterEditing(); saveState();
474
+ }
475
+
476
+ function setColor(hex, el) {
477
+ currentColor = hex;
478
+ document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active'));
479
+ if(el) el.classList.add('active');
480
+ if(['pencil','highlighter'].includes(currentTool)) setTool(currentTool);
481
+ }
482
+
483
+ function setupZoom() {
484
+ canvas.on('mouse:wheel', function(opt) {
485
+ const delta = opt.e.deltaY;
486
+ let zoom = canvas.getZoom();
487
+ zoom *= 0.999 ** delta;
488
+ if (zoom > 5) zoom = 5; if (zoom < 0.2) zoom = 0.2;
489
+ canvas.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom);
490
+ opt.e.preventDefault(); opt.e.stopPropagation();
491
+ });
492
+ }
493
+
494
+ function undo() {
495
+ if(historyStack.length <= 1) return;
496
+ historyStack.pop();
497
+ canvas.loadFromJSON(historyStack[historyStack.length-1], canvas.renderAll.bind(canvas));
498
+ }
499
+
500
+ function saveState() {
501
+ if(historyStack.length>10) historyStack.shift();
502
+ historyStack.push(JSON.stringify(canvas));
503
+ }
504
+
505
+ async function saveNotes() {
506
+ // Save as JSON instead of rasterized image
507
+ const jsonData = JSON.stringify(canvas.toJSON());
508
+
509
+ try {
510
+ const response = await fetch('/save_note_json', {
511
+ method: 'POST',
512
+ headers: { 'Content-Type': 'application/json' },
513
+ body: JSON.stringify({
514
+ image_id: activeImageId,
515
+ session_id: '{{ session_id }}',
516
+ json_data: jsonData
517
+ })
518
+ });
519
+
520
+ const result = await response.json();
521
+ if (result.success) {
522
+ // Close modal
523
+ const modal = bootstrap.Modal.getInstance(document.getElementById('notesModal'));
524
+ modal.hide();
525
+ showStatus('Notes saved!', 'success');
526
+ // Reload page to update the UI state
527
+ setTimeout(() => location.reload(), 500);
528
+ } else {
529
+ showStatus('Error saving notes: ' + result.error, 'danger');
530
+ }
531
+ } catch (e) {
532
+ showStatus('Error: ' + e.message, 'danger');
533
+ }
534
+ }
535
+
536
+ function hexToRgb(hex) {
537
+ const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
538
+ return {r,g,b};
539
+ }
540
+
541
+ async function toggleNoteInPdf(imageId, include) {
542
+ try {
543
+ const response = await fetch('/toggle_note_in_pdf', {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json' },
546
+ body: JSON.stringify({ image_id: imageId, include: include })
547
+ });
548
+ const result = await response.json();
549
+ if (!result.success) {
550
+ showStatus('Failed to update setting: ' + result.error, 'danger');
551
+ }
552
+ } catch (e) {
553
+ showStatus('Error: ' + e.message, 'danger');
554
+ }
555
+ }
556
+
557
+ async function deleteNote(imageId) {
558
+ if (!confirm('Delete this note? This cannot be undone.')) return;
559
+
560
+ try {
561
+ const response = await fetch('/delete_note', {
562
+ method: 'POST',
563
+ headers: { 'Content-Type': 'application/json' },
564
+ body: JSON.stringify({ image_id: imageId })
565
+ });
566
+ const result = await response.json();
567
+ if (result.success) {
568
+ showStatus('Note deleted', 'success');
569
+ location.reload();
570
+ } else {
571
+ showStatus('Failed to delete note: ' + result.error, 'danger');
572
+ }
573
+ } catch (e) {
574
+ showStatus('Error: ' + e.message, 'danger');
575
+ }
576
+ }
577
+ </script>
templates/cropv2.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1, viewport-fit=cover">
6
- <title>Crop Page {{ image_index + 1 }}</title>
7
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
8
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
9
  <style>
@@ -119,6 +119,58 @@
119
  .content-wrapper { flex: 1; position: relative; background-color: #181a1c; overflow: hidden; display: flex; flex-direction: column; width: 100%; }
120
  .image-pane { flex-grow: 1; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; width: 100%; height: 100%; }
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  #crop-area {
123
  position: relative;
124
  line-height: 0;
@@ -151,7 +203,7 @@
151
  }
152
 
153
  /* --- MAGNIFIER LENS --- */
154
- #magnifier {
155
  position: absolute;
156
  width: var(--lens-size);
157
  height: var(--lens-size);
@@ -167,7 +219,7 @@
167
  transition: opacity var(--transition-fast);
168
  }
169
 
170
- #magnifier::after {
171
  content: '';
172
  position: absolute;
173
  top: 50%;
@@ -295,6 +347,51 @@
295
  #box-toolbar button.delete-btn { color: #dc3545; }
296
  #box-toolbar button.delete-btn:hover { background: rgba(220, 53, 69, 0.2); }
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  /* --- FAB CONTAINER - Improved Spacing --- */
299
  .fab-container {
300
  position: absolute;
@@ -348,6 +445,8 @@
348
  transform: translateX(10px);
349
  pointer-events: none;
350
  transition: all var(--transition-normal);
 
 
351
  }
352
  .filters-panel.show {
353
  opacity: 1;
@@ -355,14 +454,16 @@
355
  pointer-events: auto;
356
  }
357
 
358
- /* Data Panel - Higher up to avoid brightness panel */
359
  #dataPanel {
360
- bottom: 150px;
 
361
  }
362
 
363
- /* Brightness Panel - Lower */
364
  #filtersPanel {
365
  bottom: 80px;
 
366
  }
367
 
368
  .form-range { height: 4px; }
@@ -419,7 +520,7 @@
419
  </div>
420
 
421
  <header class="app-header">
422
- <h1 class="header-title"><i class="bi bi-bounding-box me-2"></i>Page {{ image_index + 1 }} / {{ total_pages }}</h1>
423
  <div class="header-actions">
424
  <button id="backBtn" class="btn btn-secondary" aria-label="Back"><i class="bi bi-arrow-left"></i></button>
425
  <button id="clearBtn" class="btn btn-outline-info" aria-label="Clear All"><i class="bi bi-eraser"></i></button>
@@ -428,12 +529,13 @@
428
  </header>
429
 
430
  <div class="content-wrapper">
431
- <div class="image-pane" id="imagePane">
432
  <div id="crop-area">
 
433
  <img id="main-image" src="/image/upload/{{ image_info.filename }}" alt="Page" crossorigin="anonymous">
434
  <div id="magnifier"></div>
435
  <canvas id="draw-canvas"></canvas>
436
-
437
  <div id="box-toolbar">
438
  <button id="stitch-btn" title="Stitch"><i class="bi bi-scissors"></i></button>
439
  <button id="move-up-btn" title="Move Up"><i class="bi bi-arrow-up-circle"></i></button>
@@ -442,6 +544,22 @@
442
  </div>
443
  </div>
444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  <!-- Floating Actions -->
446
  <div class="fab-container">
447
  <!-- Data Panel - Higher up -->
@@ -500,16 +618,31 @@
500
  </div>
501
 
502
  <div class="thumbnail-bar">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  {% for page in all_pages %}
504
  <div class="thumb-item {% if page.image_index == image_index %}active{% endif %}" data-page-index="{{ page.image_index }}">
505
  <div class="thumb-loader"></div>
506
- <img data-src="/image/upload/{{ page.filename }}"
507
  alt="Page {{ page.image_index + 1 }}"
508
  data-session="{{ session_id }}"
509
  class="thumb-img">
510
  <div class="thumb-number">{{ page.image_index + 1 }}</div>
511
  </div>
512
  {% endfor %}
 
513
  </div>
514
  </div>
515
 
@@ -523,9 +656,14 @@
523
  userId: '{{ user_id }}',
524
  imageIndex: parseInt('{{ image_index }}'),
525
  totalPages: parseInt('{{ total_pages }}'),
526
- enableMagnifier: {{ 'true' if current_user.magnifier_enabled else 'false' }}
 
 
 
 
527
  };
528
- const storageKey = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.imageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.imageIndex}`;
 
529
 
530
  // --- ENHANCED PROGRESS BAR WITH BYTES ---
531
  const ProgressBar = {
@@ -741,11 +879,21 @@
741
  canvas: document.getElementById('draw-canvas'),
742
  ctx: document.getElementById('draw-canvas').getContext('2d'),
743
  toolbar: document.getElementById('box-toolbar'),
744
- magnifier: document.getElementById('magnifier')
 
 
 
 
 
 
 
745
  };
746
 
747
  let boxes = [];
 
748
  let selectedBoxIndex = -1;
 
 
749
  let isDrawing = false;
750
  let startX, startY;
751
  let dragTarget = null;
@@ -764,11 +912,17 @@
764
  // Load main image with progress tracking
765
  loadMainImageWithProgress();
766
 
 
 
 
 
 
767
  const ro = new ResizeObserver(() => requestAnimationFrame(fitImage));
768
  ro.observe(els.imagePane);
769
 
770
  loadSettings();
771
  loadBoxes();
 
772
  setupListeners();
773
  updateStitchButton();
774
 
@@ -799,29 +953,95 @@
799
  }
800
  }
801
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
  function fitImage() {
803
  if (!els.image.naturalWidth) return;
804
  const rect = els.imagePane.getBoundingClientRect();
805
- const padding = 10;
806
- const availableW = rect.width - padding;
807
- const availableH = rect.height - padding;
808
- const scale = Math.min(availableW / els.image.naturalWidth, availableH / els.image.naturalHeight);
809
-
810
- const finalW = Math.floor(els.image.naturalWidth * scale);
811
- const finalH = Math.floor(els.image.naturalHeight * scale);
812
- els.cropArea.style.width = `${finalW}px`;
813
- els.cropArea.style.height = `${finalH}px`;
814
- els.image.style.width = `${finalW}px`;
815
- els.image.style.height = `${finalH}px`;
816
- els.canvas.width = finalW;
817
- els.canvas.height = finalH;
818
-
819
- if (CONFIG.enableMagnifier) {
820
- els.magnifier.style.backgroundImage = `url('${els.image.src}')`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
821
  }
822
 
823
  if (selectedBoxIndex !== -1) updateToolbar();
 
824
  drawBoxes();
 
825
  }
826
 
827
  // --- DRAWING ENGINE ---
@@ -888,6 +1108,55 @@
888
  }
889
  }
890
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
891
  // --- MAGNIFIER LOGIC ---
892
  function updateMagnifierState(x, y, active) {
893
  if (!CONFIG.enableMagnifier) return;
@@ -919,8 +1188,8 @@
919
  }
920
 
921
  // --- INTERACTION ---
922
- function getPos(e) {
923
- const rect = els.canvas.getBoundingClientRect();
924
  const cx = e.touches ? e.touches[0].clientX : e.clientX;
925
  const cy = e.touches ? e.touches[0].clientY : e.clientY;
926
  let x = (cx - rect.left) / rect.width;
@@ -928,10 +1197,18 @@
928
  return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
929
  }
930
 
931
- function hitTest(x, y) {
932
- const pad = 30 / els.canvas.width;
933
- for (let i = boxes.length - 1; i >= 0; i--) {
934
- const b = boxes[i];
 
 
 
 
 
 
 
 
935
  for (let k of ['tl', 'tr', 'bl', 'br']) {
936
  if (Math.hypot(b[k].x - x, b[k].y - y) < pad) {
937
  return { type: 'corner', index: i, corner: k };
@@ -947,40 +1224,62 @@
947
  }
948
 
949
  function onDown(e) {
950
- if (e.target.closest('#box-toolbar')) return;
951
  e.preventDefault();
952
- const { x, y } = getPos(e);
953
-
 
 
 
 
 
 
 
954
  updateMagnifierState(x, y, true);
955
 
956
- const hit = hitTest(x, y);
957
  if (hit) {
958
- dragTarget = hit;
959
- selectedBoxIndex = hit.index;
960
- startPositions = JSON.parse(JSON.stringify(boxes[hit.index]));
 
 
 
 
 
 
 
961
  startX = x;
962
  startY = y;
963
  updateToolbar();
964
  updateStitchButton();
965
  } else {
966
- selectedBoxIndex = -1;
967
- els.toolbar.style.display = 'none';
 
 
 
 
 
968
  isDrawing = true;
969
  startX = x;
970
  startY = y;
971
  }
972
  drawBoxes();
 
973
  }
974
 
975
  function onMove(e) {
976
  if (isDrawing || dragTarget) {
977
  e.preventDefault();
978
- const { x, y } = getPos(e);
 
979
  updateMagnifierState(x, y, true);
980
-
981
  const dx = x - startX, dy = y - startY;
982
  if (dragTarget) {
983
- const b = boxes[dragTarget.index];
 
984
  if (dragTarget.type === 'corner') {
985
  b[dragTarget.corner].x = x;
986
  b[dragTarget.corner].y = y;
@@ -991,13 +1290,18 @@
991
  });
992
  }
993
  drawBoxes();
 
994
  updateToolbar();
995
  } else if (isDrawing) {
996
  drawBoxes();
997
- const sx = startX * els.canvas.width, sy = startY * els.canvas.height;
998
- const w = (x - startX) * els.canvas.width, h = (y - startY) * els.canvas.height;
999
- els.ctx.strokeStyle = 'rgba(255, 77, 77, 0.5)';
1000
- els.ctx.strokeRect(sx, sy, w, h);
 
 
 
 
1001
  }
1002
  }
1003
  }
@@ -1006,13 +1310,15 @@
1006
  updateMagnifierState(0, 0, false);
1007
 
1008
  if (isDrawing) {
1009
- const rect = els.canvas.getBoundingClientRect();
 
 
1010
  const cx = e.changedTouches ? e.changedTouches[0].clientX : e.clientX;
1011
  const cy = e.changedTouches ? e.changedTouches[0].clientY : e.clientY;
1012
  let endX = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
1013
  let endY = Math.max(0, Math.min(1, (cy - rect.top) / rect.height));
1014
- if (Math.abs(endX - startX) * els.canvas.width > 20) {
1015
- boxes.push({
1016
  id: Date.now(),
1017
  tl: { x: Math.min(startX, endX), y: Math.min(startY, endY) },
1018
  tr: { x: Math.max(startX, endX), y: Math.min(startY, endY) },
@@ -1020,13 +1326,19 @@
1020
  br: { x: Math.max(startX, endX), y: Math.max(startY, endY) },
1021
  remote_stitch_source: null
1022
  });
1023
- selectedBoxIndex = boxes.length - 1;
 
 
 
 
1024
  }
1025
  }
1026
  isDrawing = false;
1027
  dragTarget = null;
1028
  saveBoxes();
 
1029
  drawBoxes();
 
1030
  updateToolbar();
1031
  updateStitchButton();
1032
  }
@@ -1035,6 +1347,13 @@
1035
  function setupListeners() {
1036
  els.canvas.addEventListener('mousedown', onDown);
1037
  els.canvas.addEventListener('touchstart', onDown, { passive: false });
 
 
 
 
 
 
 
1038
  document.addEventListener('mousemove', onMove);
1039
  document.addEventListener('touchmove', onMove, { passive: false });
1040
  document.addEventListener('mouseup', onUp);
@@ -1045,27 +1364,53 @@
1045
  };
1046
 
1047
  document.getElementById('clearBtn').onclick = () => {
1048
- if (confirm("Clear all boxes?")) {
 
1049
  boxes = [];
1050
  selectedBoxIndex = -1;
1051
  saveBoxes();
1052
  drawBoxes();
1053
  els.toolbar.style.display = 'none';
 
 
 
 
 
 
 
 
1054
  }
1055
  };
1056
 
1057
  document.getElementById('delete-btn').onclick = (e) => {
1058
  e.stopPropagation();
1059
- boxes.splice(selectedBoxIndex, 1);
1060
- selectedBoxIndex = -1;
1061
- els.toolbar.style.display = 'none';
1062
- saveBoxes();
1063
- drawBoxes();
 
 
 
 
 
 
 
 
1064
  };
1065
-
1066
  document.getElementById('move-up-btn').onclick = (e) => {
1067
  e.stopPropagation();
1068
- if (selectedBoxIndex < boxes.length - 1) {
 
 
 
 
 
 
 
 
 
1069
  const b = boxes.splice(selectedBoxIndex, 1)[0];
1070
  boxes.splice(selectedBoxIndex + 1, 0, b);
1071
  selectedBoxIndex++;
@@ -1074,10 +1419,19 @@
1074
  updateToolbar();
1075
  }
1076
  };
1077
-
1078
  document.getElementById('move-down-btn').onclick = (e) => {
1079
  e.stopPropagation();
1080
- if (selectedBoxIndex > 0) {
 
 
 
 
 
 
 
 
 
1081
  const b = boxes.splice(selectedBoxIndex, 1)[0];
1082
  boxes.splice(selectedBoxIndex - 1, 0, b);
1083
  selectedBoxIndex--;
@@ -1086,10 +1440,48 @@
1086
  updateToolbar();
1087
  }
1088
  };
1089
-
1090
  document.getElementById('stitch-btn').onclick = handleStitch;
1091
  document.getElementById('processBtn').onclick = processPage;
1092
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1093
  // Data Entry
1094
  document.getElementById('dataToggle').onclick = () => {
1095
  const panel = document.getElementById('dataPanel');
@@ -1114,10 +1506,13 @@
1114
  'box-marked': 'marked_solution',
1115
  'box-actual': 'actual_solution'
1116
  };
1117
-
1118
  Object.keys(dataFields).forEach(id => {
1119
  document.getElementById(id).addEventListener('input', (e) => {
1120
- if (selectedBoxIndex > -1) {
 
 
 
1121
  boxes[selectedBoxIndex][dataFields[id]] = e.target.value;
1122
  saveBoxes();
1123
  }
@@ -1203,16 +1598,23 @@
1203
  }
1204
 
1205
  function updateDataPanel() {
1206
- const b = selectedBoxIndex > -1 ? boxes[selectedBoxIndex] : null;
 
 
 
 
 
 
 
1207
  const msg = document.getElementById('no-selection-msg');
1208
  const form = document.getElementById('data-form');
1209
-
1210
  if (!b) {
1211
  if (msg) msg.style.display = 'block';
1212
  if (form) form.style.display = 'none';
1213
  return;
1214
  }
1215
-
1216
  if (msg) msg.style.display = 'none';
1217
  if (form) form.style.display = 'block';
1218
 
@@ -1224,29 +1626,59 @@
1224
 
1225
  function updateToolbar() {
1226
  updateDataPanel();
1227
- if (selectedBoxIndex === -1) {
 
 
1228
  els.toolbar.classList.remove('show');
1229
  setTimeout(() => { if (!els.toolbar.classList.contains('show')) els.toolbar.style.display = 'none'; }, 200);
1230
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1231
  }
1232
- const b = boxes[selectedBoxIndex];
1233
- const p = (pt) => ({ x: pt.x * els.canvas.width, y: pt.y * els.canvas.height });
1234
- const maxX = Math.max(p(b.tr).x, p(b.br).x);
1235
- const minY = Math.min(p(b.tl).y, p(b.tr).y);
1236
- let left = maxX - 180;
1237
- if (left < 0) left = 0;
1238
- let top = minY + 10;
1239
- els.toolbar.style.left = `${left}px`;
1240
- els.toolbar.style.top = `${top}px`;
1241
- els.toolbar.style.display = 'flex';
1242
- requestAnimationFrame(() => els.toolbar.classList.add('show'));
1243
  }
1244
 
1245
  function updateStitchButton() {
1246
  const btn = document.getElementById('stitch-btn');
1247
  const icon = btn.querySelector('i');
1248
  const isBuffer = stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId;
1249
- const isStitched = selectedBoxIndex > -1 && boxes[selectedBoxIndex]?.remote_stitch_source;
 
 
 
 
 
 
 
 
1250
  if (isBuffer) {
1251
  icon.className = 'bi bi-link-45deg';
1252
  btn.style.color = '#0dcaf0';
@@ -1257,12 +1689,48 @@
1257
  icon.className = 'bi bi-scissors';
1258
  btn.style.color = '#e9ecef';
1259
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1260
  }
1261
 
1262
  function handleStitch(e) {
1263
  e.stopPropagation();
1264
- if (selectedBoxIndex === -1) return;
1265
- const b = boxes[selectedBoxIndex];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1266
  if (stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId) {
1267
  b.remote_stitch_source = {
1268
  page_index: stitchBuffer.page_index,
@@ -1280,15 +1748,16 @@
1280
  const cleanBox = { ...b, x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1281
  stitchBuffer = {
1282
  session_id: CONFIG.sessionId,
1283
- page_index: CONFIG.imageIndex,
1284
  box: cleanBox
1285
  };
1286
  localStorage.setItem('gemini_stitch_buffer', JSON.stringify(stitchBuffer));
1287
  toast('Copied!');
1288
  }
1289
- saveBoxes();
1290
  updateStitchButton();
1291
  drawBoxes();
 
1292
  }
1293
 
1294
  function toast(msg) {
@@ -1300,51 +1769,90 @@
1300
  }
1301
 
1302
  async function processPage() {
1303
- if (!boxes.length) {
 
 
 
1304
  // Show non-intrusive toast and continue
1305
- toast('Skipping page...');
1306
  await new Promise(r => setTimeout(r, 300)); // Brief delay for toast visibility
1307
  }
1308
 
1309
- ProgressBar.show(0.3);
1310
  document.getElementById('loader-overlay').style.display = 'flex';
1311
-
1312
- const finalBoxes = boxes.map(b => ({
1313
- ...b,
1314
- x: Math.min(b.tl.x, b.bl.x),
1315
- y: Math.min(b.tl.y, b.tr.y),
1316
- w: Math.max(b.tr.x, b.br.x) - Math.min(b.tl.x, b.bl.x),
1317
- h: Math.max(b.bl.y, b.br.y) - Math.min(b.tl.y, b.tr.y)
1318
- }));
1319
-
1320
- const cv = document.createElement('canvas');
1321
- cv.width = els.image.naturalWidth;
1322
- cv.height = els.image.naturalHeight;
1323
- const c = cv.getContext('2d');
1324
- c.filter = els.image.style.filter;
1325
- c.drawImage(els.image, 0, 0);
1326
-
1327
- ProgressBar.update(0.5);
1328
-
1329
  try {
1330
- const res = await fetch('/process_crop_v2', {
1331
- method: 'POST',
1332
- headers: { 'Content-Type': 'application/json' },
1333
- body: JSON.stringify({
1334
- session_id: CONFIG.sessionId,
1335
- image_index: CONFIG.imageIndex,
1336
- boxes: finalBoxes,
1337
- imageData: cv.toDataURL('image/jpeg', 0.85)
1338
- })
1339
- });
1340
-
1341
- if (!res.ok) throw new Error(await res.text());
1342
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1343
  ProgressBar.update(1);
1344
  setTimeout(() => {
1345
  const next = CONFIG.imageIndex + 1;
1346
- location.href = next < CONFIG.totalPages
1347
- ? `/cropv2/${CONFIG.sessionId}/${next}`
1348
  : `/question_entry_v2/${CONFIG.sessionId}`;
1349
  }, 200);
1350
  } catch (e) {
@@ -1357,7 +1865,13 @@
1357
  function saveBoxes() {
1358
  localStorage.setItem(storageKey, JSON.stringify(boxes));
1359
  }
1360
-
 
 
 
 
 
 
1361
  function loadBoxes() {
1362
  try {
1363
  const s = localStorage.getItem(storageKey);
@@ -1375,17 +1889,45 @@
1375
  } catch (e) {}
1376
  }
1377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1378
  function updateFilters() {
1379
  const b = document.getElementById('brightness').value;
1380
  const c = document.getElementById('contrast').value;
1381
  const g = document.getElementById('gamma').value;
1382
-
1383
  document.getElementById('val-b').innerText = b;
1384
  document.getElementById('val-c').innerText = c;
1385
  document.getElementById('val-g').innerText = g;
1386
-
1387
- els.image.style.filter = `brightness(${100 + parseFloat(b)}%) contrast(${c})`;
1388
- els.magnifier.style.filter = els.image.style.filter;
 
 
 
 
 
 
 
 
 
 
1389
  localStorage.setItem('pdfFilters', JSON.stringify({ b, c, g }));
1390
  }
1391
 
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1, viewport-fit=cover">
6
+ <title>Crop {% if two_page_mode %}Pages {{ left_page_index + 1 }}-{{ right_page_index + 1 }}{% else %}Page {{ image_index + 1 }}{% endif %}</title>
7
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
8
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
9
  <style>
 
119
  .content-wrapper { flex: 1; position: relative; background-color: #181a1c; overflow: hidden; display: flex; flex-direction: column; width: 100%; }
120
  .image-pane { flex-grow: 1; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; width: 100%; height: 100%; }
121
 
122
+ /* Two-Page Layout */
123
+ .image-pane.two-page-mode {
124
+ gap: 8px;
125
+ padding: 4px;
126
+ flex-direction: row;
127
+ flex-wrap: nowrap;
128
+ }
129
+ .two-page-mode #crop-area,
130
+ .two-page-mode .crop-area-right {
131
+ flex: 0 0 auto;
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ }
136
+ .page-label {
137
+ position: absolute;
138
+ top: 8px;
139
+ left: 8px;
140
+ background: rgba(0, 0, 0, 0.75);
141
+ color: #fff;
142
+ padding: 4px 10px;
143
+ border-radius: 4px;
144
+ font-size: 12px;
145
+ font-weight: 600;
146
+ z-index: 15;
147
+ pointer-events: none;
148
+ }
149
+ .crop-area-right {
150
+ position: relative;
151
+ line-height: 0;
152
+ box-shadow: 0 0 30px rgba(0,0,0,0.5);
153
+ user-select: none;
154
+ -webkit-user-select: none;
155
+ transition: opacity var(--transition-normal);
156
+ }
157
+ .crop-area-right.loading { opacity: 0.5; }
158
+ .crop-area-right img {
159
+ display: block;
160
+ pointer-events: none;
161
+ opacity: 0;
162
+ transition: opacity 0.4s ease-out;
163
+ }
164
+ .crop-area-right img.loaded { opacity: 1; }
165
+ .crop-area-right canvas {
166
+ position: absolute;
167
+ top: 0; left: 0;
168
+ width: 100%; height: 100%;
169
+ z-index: 10;
170
+ cursor: crosshair;
171
+ touch-action: none;
172
+ }
173
+
174
  #crop-area {
175
  position: relative;
176
  line-height: 0;
 
203
  }
204
 
205
  /* --- MAGNIFIER LENS --- */
206
+ #magnifier, #magnifier-right {
207
  position: absolute;
208
  width: var(--lens-size);
209
  height: var(--lens-size);
 
219
  transition: opacity var(--transition-fast);
220
  }
221
 
222
+ #magnifier::after, #magnifier-right::after {
223
  content: '';
224
  position: absolute;
225
  top: 50%;
 
347
  #box-toolbar button.delete-btn { color: #dc3545; }
348
  #box-toolbar button.delete-btn:hover { background: rgba(220, 53, 69, 0.2); }
349
 
350
+ /* Secondary toolbar for right page */
351
+ .box-toolbar-secondary {
352
+ position: absolute;
353
+ background: rgba(33, 37, 41, 0.95);
354
+ border: 1px solid #6c757d;
355
+ border-radius: 50px;
356
+ padding: 8px 16px;
357
+ display: none;
358
+ gap: 16px;
359
+ backdrop-filter: blur(8px);
360
+ z-index: 100;
361
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
362
+ opacity: 0;
363
+ transform: translateY(10px);
364
+ transition: all var(--transition-normal);
365
+ }
366
+ .box-toolbar-secondary.show {
367
+ opacity: 1;
368
+ transform: translateY(0);
369
+ }
370
+ .box-toolbar-secondary button {
371
+ background: transparent;
372
+ border: none;
373
+ color: #e9ecef;
374
+ width: 32px;
375
+ height: 32px;
376
+ font-size: 1.4rem;
377
+ padding: 0;
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ transition: all var(--transition-fast);
382
+ border-radius: 50%;
383
+ }
384
+ .box-toolbar-secondary button:hover {
385
+ background: rgba(255,255,255,0.1);
386
+ transform: scale(1.1);
387
+ }
388
+ .box-toolbar-secondary button:active {
389
+ transform: scale(0.9);
390
+ color: #fff;
391
+ }
392
+ .box-toolbar-secondary button.delete-btn { color: #dc3545; }
393
+ .box-toolbar-secondary button.delete-btn:hover { background: rgba(220, 53, 69, 0.2); }
394
+
395
  /* --- FAB CONTAINER - Improved Spacing --- */
396
  .fab-container {
397
  position: absolute;
 
445
  transform: translateX(10px);
446
  pointer-events: none;
447
  transition: all var(--transition-normal);
448
+ max-height: calc(100vh - 200px);
449
+ overflow-y: auto;
450
  }
451
  .filters-panel.show {
452
  opacity: 1;
 
454
  pointer-events: auto;
455
  }
456
 
457
+ /* Data Panel - Position from bottom of FAB container */
458
  #dataPanel {
459
+ bottom: 80px;
460
+ right: 76px;
461
  }
462
 
463
+ /* Brightness Panel - Position from bottom of FAB container */
464
  #filtersPanel {
465
  bottom: 80px;
466
+ right: 76px;
467
  }
468
 
469
  .form-range { height: 4px; }
 
520
  </div>
521
 
522
  <header class="app-header">
523
+ <h1 class="header-title"><i class="bi bi-bounding-box me-2"></i>{% if two_page_mode %}Pages {{ left_page_index + 1 }}-{{ right_page_index + 1 if right_image_info else left_page_index + 1 }} / {{ total_pages }}{% else %}Page {{ image_index + 1 }} / {{ total_pages }}{% endif %}</h1>
524
  <div class="header-actions">
525
  <button id="backBtn" class="btn btn-secondary" aria-label="Back"><i class="bi bi-arrow-left"></i></button>
526
  <button id="clearBtn" class="btn btn-outline-info" aria-label="Clear All"><i class="bi bi-eraser"></i></button>
 
529
  </header>
530
 
531
  <div class="content-wrapper">
532
+ <div class="image-pane{% if two_page_mode %} two-page-mode{% endif %}" id="imagePane">
533
  <div id="crop-area">
534
+ {% if two_page_mode %}<div class="page-label">Page {{ left_page_index + 1 }}</div>{% endif %}
535
  <img id="main-image" src="/image/upload/{{ image_info.filename }}" alt="Page" crossorigin="anonymous">
536
  <div id="magnifier"></div>
537
  <canvas id="draw-canvas"></canvas>
538
+
539
  <div id="box-toolbar">
540
  <button id="stitch-btn" title="Stitch"><i class="bi bi-scissors"></i></button>
541
  <button id="move-up-btn" title="Move Up"><i class="bi bi-arrow-up-circle"></i></button>
 
544
  </div>
545
  </div>
546
 
547
+ {% if two_page_mode and right_image_info %}
548
+ <div class="crop-area-right" id="crop-area-right">
549
+ <div class="page-label">Page {{ right_page_index + 1 }}</div>
550
+ <img id="right-image" src="/image/upload/{{ right_image_info.filename }}" alt="Right Page" crossorigin="anonymous">
551
+ <div id="magnifier-right"></div>
552
+ <canvas id="draw-canvas-right"></canvas>
553
+
554
+ <div id="box-toolbar-right" class="box-toolbar-secondary">
555
+ <button id="stitch-btn-right" title="Stitch"><i class="bi bi-scissors"></i></button>
556
+ <button id="move-up-btn-right" title="Move Up"><i class="bi bi-arrow-up-circle"></i></button>
557
+ <button id="move-down-btn-right" title="Move Down"><i class="bi bi-arrow-down-circle"></i></button>
558
+ <button id="delete-btn-right" title="Delete Box" class="delete-btn"><i class="bi bi-trash"></i></button>
559
+ </div>
560
+ </div>
561
+ {% endif %}
562
+
563
  <!-- Floating Actions -->
564
  <div class="fab-container">
565
  <!-- Data Panel - Higher up -->
 
618
  </div>
619
 
620
  <div class="thumbnail-bar">
621
+ {% if two_page_mode %}
622
+ {% for i in range((all_pages|length + 1) // 2) %}
623
+ {% set left_idx = i * 2 %}
624
+ {% set right_idx = left_idx + 1 %}
625
+ <div class="thumb-item {% if i == image_index %}active{% endif %}" data-page-index="{{ i }}">
626
+ <div class="thumb-loader"></div>
627
+ <img data-src="/image/upload/{{ all_pages[left_idx].filename }}"
628
+ alt="Pages {{ left_idx + 1 }}-{{ right_idx + 1 }}"
629
+ data-session="{{ session_id }}"
630
+ class="thumb-img">
631
+ <div class="thumb-number">{{ left_idx + 1 }}-{{ right_idx + 1 if right_idx < all_pages|length else left_idx + 1 }}</div>
632
+ </div>
633
+ {% endfor %}
634
+ {% else %}
635
  {% for page in all_pages %}
636
  <div class="thumb-item {% if page.image_index == image_index %}active{% endif %}" data-page-index="{{ page.image_index }}">
637
  <div class="thumb-loader"></div>
638
+ <img data-src="/image/upload/{{ page.filename }}"
639
  alt="Page {{ page.image_index + 1 }}"
640
  data-session="{{ session_id }}"
641
  class="thumb-img">
642
  <div class="thumb-number">{{ page.image_index + 1 }}</div>
643
  </div>
644
  {% endfor %}
645
+ {% endif %}
646
  </div>
647
  </div>
648
 
 
656
  userId: '{{ user_id }}',
657
  imageIndex: parseInt('{{ image_index }}'),
658
  totalPages: parseInt('{{ total_pages }}'),
659
+ enableMagnifier: {{ 'true' if current_user.magnifier_enabled else 'false' }},
660
+ twoPageMode: {{ 'true' if two_page_mode else 'false' }},
661
+ leftPageIndex: parseInt('{{ left_page_index|default(image_index) }}'),
662
+ rightPageIndex: parseInt('{{ right_page_index|default(-1) }}'),
663
+ hasRightPage: {{ 'true' if two_page_mode and right_image_info else 'false' }}
664
  };
665
+ const storageKey = CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.leftPageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.leftPageIndex}`;
666
+ const storageKeyRight = CONFIG.hasRightPage ? (CONFIG.userId ? `cropState_${CONFIG.userId}_${CONFIG.sessionId}_${CONFIG.rightPageIndex}` : `cropState_${CONFIG.sessionId}_${CONFIG.rightPageIndex}`) : null;
667
 
668
  // --- ENHANCED PROGRESS BAR WITH BYTES ---
669
  const ProgressBar = {
 
879
  canvas: document.getElementById('draw-canvas'),
880
  ctx: document.getElementById('draw-canvas').getContext('2d'),
881
  toolbar: document.getElementById('box-toolbar'),
882
+ magnifier: document.getElementById('magnifier'),
883
+ // Right page elements (two-page mode)
884
+ rightImage: CONFIG.hasRightPage ? document.getElementById('right-image') : null,
885
+ rightCropArea: CONFIG.hasRightPage ? document.getElementById('crop-area-right') : null,
886
+ rightCanvas: CONFIG.hasRightPage ? document.getElementById('draw-canvas-right') : null,
887
+ rightCtx: CONFIG.hasRightPage ? document.getElementById('draw-canvas-right').getContext('2d') : null,
888
+ rightToolbar: CONFIG.hasRightPage ? document.getElementById('box-toolbar-right') : null,
889
+ rightMagnifier: CONFIG.hasRightPage ? document.getElementById('magnifier-right') : null
890
  };
891
 
892
  let boxes = [];
893
+ let boxesRight = []; // For right page in two-page mode
894
  let selectedBoxIndex = -1;
895
+ let selectedBoxIndexRight = -1;
896
+ let activePane = 'left'; // Which pane is currently active
897
  let isDrawing = false;
898
  let startX, startY;
899
  let dragTarget = null;
 
912
  // Load main image with progress tracking
913
  loadMainImageWithProgress();
914
 
915
+ // Load right image if in two-page mode
916
+ if (CONFIG.hasRightPage) {
917
+ loadRightImageWithProgress();
918
+ }
919
+
920
  const ro = new ResizeObserver(() => requestAnimationFrame(fitImage));
921
  ro.observe(els.imagePane);
922
 
923
  loadSettings();
924
  loadBoxes();
925
+ if (CONFIG.hasRightPage) loadBoxesRight();
926
  setupListeners();
927
  updateStitchButton();
928
 
 
953
  }
954
  }
955
 
956
+ async function loadRightImageWithProgress() {
957
+ if (!CONFIG.hasRightPage || !els.rightImage) return;
958
+
959
+ const imageUrl = els.rightImage.src;
960
+ els.rightCropArea.classList.add('loading');
961
+
962
+ try {
963
+ const blob = await ThumbCache.fetchWithProgress(imageUrl, false); // Don't show progress for second image
964
+ els.rightImage.src = URL.createObjectURL(blob);
965
+ els.rightImage.onload = () => {
966
+ els.rightImage.classList.add('loaded');
967
+ els.rightCropArea.classList.remove('loading');
968
+ fitImage();
969
+ };
970
+ } catch (err) {
971
+ els.rightImage.onload = () => {
972
+ els.rightImage.classList.add('loaded');
973
+ els.rightCropArea.classList.remove('loading');
974
+ fitImage();
975
+ };
976
+ }
977
+ }
978
+
979
  function fitImage() {
980
  if (!els.image.naturalWidth) return;
981
  const rect = els.imagePane.getBoundingClientRect();
982
+ const padding = 4;
983
+ const gap = 8;
984
+
985
+ if (CONFIG.twoPageMode) {
986
+ // Two-page mode: always side by side, maximize space
987
+ const halfWidth = (rect.width - gap) / 2 - padding;
988
+ const fullHeight = rect.height - padding * 2;
989
+
990
+ // Fit left image - maximize space
991
+ const scaleLeft = Math.min(halfWidth / els.image.naturalWidth, fullHeight / els.image.naturalHeight);
992
+ const finalWLeft = Math.floor(els.image.naturalWidth * scaleLeft);
993
+ const finalHLeft = Math.floor(els.image.naturalHeight * scaleLeft);
994
+ els.cropArea.style.width = `${finalWLeft}px`;
995
+ els.cropArea.style.height = `${finalHLeft}px`;
996
+ els.image.style.width = `${finalWLeft}px`;
997
+ els.image.style.height = `${finalHLeft}px`;
998
+ els.canvas.width = finalWLeft;
999
+ els.canvas.height = finalHLeft;
1000
+
1001
+ if (CONFIG.enableMagnifier) {
1002
+ els.magnifier.style.backgroundImage = `url('${els.image.src}')`;
1003
+ }
1004
+
1005
+ // Fit right image if present - maximize space
1006
+ if (CONFIG.hasRightPage && els.rightImage && els.rightImage.naturalWidth) {
1007
+ const scaleRight = Math.min(halfWidth / els.rightImage.naturalWidth, fullHeight / els.rightImage.naturalHeight);
1008
+ const finalWRight = Math.floor(els.rightImage.naturalWidth * scaleRight);
1009
+ const finalHRight = Math.floor(els.rightImage.naturalHeight * scaleRight);
1010
+ els.rightCropArea.style.width = `${finalWRight}px`;
1011
+ els.rightCropArea.style.height = `${finalHRight}px`;
1012
+ els.rightImage.style.width = `${finalWRight}px`;
1013
+ els.rightImage.style.height = `${finalHRight}px`;
1014
+ els.rightCanvas.width = finalWRight;
1015
+ els.rightCanvas.height = finalHRight;
1016
+
1017
+ if (CONFIG.enableMagnifier && els.rightMagnifier) {
1018
+ els.rightMagnifier.style.backgroundImage = `url('${els.rightImage.src}')`;
1019
+ }
1020
+ }
1021
+ } else {
1022
+ // Single-page mode - use full available space
1023
+ const availableW = rect.width - padding * 2;
1024
+ const availableH = rect.height - padding * 2;
1025
+ const scale = Math.min(availableW / els.image.naturalWidth, availableH / els.image.naturalHeight);
1026
+
1027
+ const finalW = Math.floor(els.image.naturalWidth * scale);
1028
+ const finalH = Math.floor(els.image.naturalHeight * scale);
1029
+ els.cropArea.style.width = `${finalW}px`;
1030
+ els.cropArea.style.height = `${finalH}px`;
1031
+ els.image.style.width = `${finalW}px`;
1032
+ els.image.style.height = `${finalH}px`;
1033
+ els.canvas.width = finalW;
1034
+ els.canvas.height = finalH;
1035
+
1036
+ if (CONFIG.enableMagnifier) {
1037
+ els.magnifier.style.backgroundImage = `url('${els.image.src}')`;
1038
+ }
1039
  }
1040
 
1041
  if (selectedBoxIndex !== -1) updateToolbar();
1042
+ if (selectedBoxIndexRight !== -1 && CONFIG.hasRightPage) updateToolbar();
1043
  drawBoxes();
1044
+ if (CONFIG.hasRightPage) drawBoxesRight();
1045
  }
1046
 
1047
  // --- DRAWING ENGINE ---
 
1108
  }
1109
  }
1110
 
1111
+ // --- DRAWING ENGINE FOR RIGHT PAGE ---
1112
+ function drawBoxesRight() {
1113
+ if (!CONFIG.hasRightPage || !els.rightCtx) return;
1114
+
1115
+ els.rightCtx.clearRect(0, 0, els.rightCanvas.width, els.rightCanvas.height);
1116
+
1117
+ boxesRight.forEach((box, index) => {
1118
+ const isSelected = index === selectedBoxIndexRight && activePane === 'right';
1119
+ const isStitched = box.remote_stitch_source != null;
1120
+ const p = (pt) => ({ x: pt.x * els.rightCanvas.width, y: pt.y * els.rightCanvas.height });
1121
+
1122
+ els.rightCtx.lineWidth = isSelected ? 3 : 2;
1123
+ els.rightCtx.strokeStyle = isSelected ? '#ff4d4d' : (isStitched ? '#0dcaf0' : '#ffc107');
1124
+ els.rightCtx.fillStyle = isSelected ? 'rgba(255, 77, 77, 0.15)' : (isStitched ? 'rgba(13, 202, 240, 0.2)' : 'rgba(255, 193, 7, 0.1)');
1125
+
1126
+ els.rightCtx.beginPath();
1127
+ els.rightCtx.moveTo(p(box.tl).x, p(box.tl).y);
1128
+ els.rightCtx.lineTo(p(box.tr).x, p(box.tr).y);
1129
+ els.rightCtx.lineTo(p(box.br).x, p(box.br).y);
1130
+ els.rightCtx.lineTo(p(box.bl).x, p(box.bl).y);
1131
+ els.rightCtx.closePath();
1132
+ els.rightCtx.stroke();
1133
+ els.rightCtx.fill();
1134
+
1135
+ if (isSelected) {
1136
+ els.rightCtx.fillStyle = 'white';
1137
+ ['tl', 'tr', 'bl', 'br'].forEach(k => {
1138
+ els.rightCtx.beginPath();
1139
+ els.rightCtx.arc(p(box[k]).x, p(box[k]).y, 8, 0, Math.PI * 2);
1140
+ els.rightCtx.fill();
1141
+ els.rightCtx.stroke();
1142
+ });
1143
+ }
1144
+
1145
+ const cx = (p(box.tl).x + p(box.br).x) / 2;
1146
+ const cy = (p(box.tl).y + p(box.br).y) / 2;
1147
+ els.rightCtx.font = "bold 24px system-ui";
1148
+ els.rightCtx.fillStyle = "white";
1149
+ els.rightCtx.shadowColor = "rgba(0,0,0,0.8)";
1150
+ els.rightCtx.shadowBlur = 6;
1151
+ els.rightCtx.fillText(index + 1, cx - 6, cy + 8);
1152
+ if (isStitched) {
1153
+ els.rightCtx.font = "20px system-ui";
1154
+ els.rightCtx.fillText("🔗", p(box.tr).x - 28, p(box.tr).y + 24);
1155
+ }
1156
+ els.rightCtx.shadowBlur = 0;
1157
+ });
1158
+ }
1159
+
1160
  // --- MAGNIFIER LOGIC ---
1161
  function updateMagnifierState(x, y, active) {
1162
  if (!CONFIG.enableMagnifier) return;
 
1188
  }
1189
 
1190
  // --- INTERACTION ---
1191
+ function getPos(e, canvas = els.canvas) {
1192
+ const rect = canvas.getBoundingClientRect();
1193
  const cx = e.touches ? e.touches[0].clientX : e.clientX;
1194
  const cy = e.touches ? e.touches[0].clientY : e.clientY;
1195
  let x = (cx - rect.left) / rect.width;
 
1197
  return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
1198
  }
1199
 
1200
+ function isEventOnRightCanvas(e) {
1201
+ if (!CONFIG.hasRightPage || !els.rightCanvas) return false;
1202
+ const rect = els.rightCanvas.getBoundingClientRect();
1203
+ const cx = e.touches ? e.touches[0].clientX : e.clientX;
1204
+ const cy = e.touches ? e.touches[0].clientY : e.clientY;
1205
+ return cx >= rect.left && cx <= rect.right && cy >= rect.top && cy <= rect.bottom;
1206
+ }
1207
+
1208
+ function hitTest(x, y, boxArray = boxes, canvas = els.canvas) {
1209
+ const pad = 30 / canvas.width;
1210
+ for (let i = boxArray.length - 1; i >= 0; i--) {
1211
+ const b = boxArray[i];
1212
  for (let k of ['tl', 'tr', 'bl', 'br']) {
1213
  if (Math.hypot(b[k].x - x, b[k].y - y) < pad) {
1214
  return { type: 'corner', index: i, corner: k };
 
1224
  }
1225
 
1226
  function onDown(e) {
1227
+ if (e.target.closest('#box-toolbar') || e.target.closest('.box-toolbar-secondary')) return;
1228
  e.preventDefault();
1229
+
1230
+ // Determine which pane is being interacted with
1231
+ const onRight = isEventOnRightCanvas(e);
1232
+ activePane = onRight ? 'right' : 'left';
1233
+ const currentCanvas = onRight ? els.rightCanvas : els.canvas;
1234
+ const currentBoxes = onRight ? boxesRight : boxes;
1235
+
1236
+ const { x, y } = getPos(e, currentCanvas);
1237
+
1238
  updateMagnifierState(x, y, true);
1239
 
1240
+ const hit = hitTest(x, y, currentBoxes, currentCanvas);
1241
  if (hit) {
1242
+ dragTarget = { ...hit, pane: activePane };
1243
+ if (onRight) {
1244
+ selectedBoxIndexRight = hit.index;
1245
+ selectedBoxIndex = -1;
1246
+ startPositions = JSON.parse(JSON.stringify(boxesRight[hit.index]));
1247
+ } else {
1248
+ selectedBoxIndex = hit.index;
1249
+ selectedBoxIndexRight = -1;
1250
+ startPositions = JSON.parse(JSON.stringify(boxes[hit.index]));
1251
+ }
1252
  startX = x;
1253
  startY = y;
1254
  updateToolbar();
1255
  updateStitchButton();
1256
  } else {
1257
+ if (onRight) {
1258
+ selectedBoxIndexRight = -1;
1259
+ if (els.rightToolbar) els.rightToolbar.style.display = 'none';
1260
+ } else {
1261
+ selectedBoxIndex = -1;
1262
+ els.toolbar.style.display = 'none';
1263
+ }
1264
  isDrawing = true;
1265
  startX = x;
1266
  startY = y;
1267
  }
1268
  drawBoxes();
1269
+ if (CONFIG.hasRightPage) drawBoxesRight();
1270
  }
1271
 
1272
  function onMove(e) {
1273
  if (isDrawing || dragTarget) {
1274
  e.preventDefault();
1275
+ const currentCanvas = activePane === 'right' ? els.rightCanvas : els.canvas;
1276
+ const { x, y } = getPos(e, currentCanvas);
1277
  updateMagnifierState(x, y, true);
1278
+
1279
  const dx = x - startX, dy = y - startY;
1280
  if (dragTarget) {
1281
+ const currentBoxes = dragTarget.pane === 'right' ? boxesRight : boxes;
1282
+ const b = currentBoxes[dragTarget.index];
1283
  if (dragTarget.type === 'corner') {
1284
  b[dragTarget.corner].x = x;
1285
  b[dragTarget.corner].y = y;
 
1290
  });
1291
  }
1292
  drawBoxes();
1293
+ if (CONFIG.hasRightPage) drawBoxesRight();
1294
  updateToolbar();
1295
  } else if (isDrawing) {
1296
  drawBoxes();
1297
+ if (CONFIG.hasRightPage) drawBoxesRight();
1298
+ const ctx = activePane === 'right' ? els.rightCtx : els.ctx;
1299
+ const canvasWidth = activePane === 'right' ? els.rightCanvas.width : els.canvas.width;
1300
+ const canvasHeight = activePane === 'right' ? els.rightCanvas.height : els.canvas.height;
1301
+ const sx = startX * canvasWidth, sy = startY * canvasHeight;
1302
+ const w = (x - startX) * canvasWidth, h = (y - startY) * canvasHeight;
1303
+ ctx.strokeStyle = 'rgba(255, 77, 77, 0.5)';
1304
+ ctx.strokeRect(sx, sy, w, h);
1305
  }
1306
  }
1307
  }
 
1310
  updateMagnifierState(0, 0, false);
1311
 
1312
  if (isDrawing) {
1313
+ const currentCanvas = activePane === 'right' ? els.rightCanvas : els.canvas;
1314
+ const currentBoxes = activePane === 'right' ? boxesRight : boxes;
1315
+ const rect = currentCanvas.getBoundingClientRect();
1316
  const cx = e.changedTouches ? e.changedTouches[0].clientX : e.clientX;
1317
  const cy = e.changedTouches ? e.changedTouches[0].clientY : e.clientY;
1318
  let endX = Math.max(0, Math.min(1, (cx - rect.left) / rect.width));
1319
  let endY = Math.max(0, Math.min(1, (cy - rect.top) / rect.height));
1320
+ if (Math.abs(endX - startX) * currentCanvas.width > 20) {
1321
+ currentBoxes.push({
1322
  id: Date.now(),
1323
  tl: { x: Math.min(startX, endX), y: Math.min(startY, endY) },
1324
  tr: { x: Math.max(startX, endX), y: Math.min(startY, endY) },
 
1326
  br: { x: Math.max(startX, endX), y: Math.max(startY, endY) },
1327
  remote_stitch_source: null
1328
  });
1329
+ if (activePane === 'right') {
1330
+ selectedBoxIndexRight = boxesRight.length - 1;
1331
+ } else {
1332
+ selectedBoxIndex = boxes.length - 1;
1333
+ }
1334
  }
1335
  }
1336
  isDrawing = false;
1337
  dragTarget = null;
1338
  saveBoxes();
1339
+ if (CONFIG.hasRightPage) saveBoxesRight();
1340
  drawBoxes();
1341
+ if (CONFIG.hasRightPage) drawBoxesRight();
1342
  updateToolbar();
1343
  updateStitchButton();
1344
  }
 
1347
  function setupListeners() {
1348
  els.canvas.addEventListener('mousedown', onDown);
1349
  els.canvas.addEventListener('touchstart', onDown, { passive: false });
1350
+
1351
+ // Add listeners for right canvas in two-page mode
1352
+ if (CONFIG.hasRightPage && els.rightCanvas) {
1353
+ els.rightCanvas.addEventListener('mousedown', onDown);
1354
+ els.rightCanvas.addEventListener('touchstart', onDown, { passive: false });
1355
+ }
1356
+
1357
  document.addEventListener('mousemove', onMove);
1358
  document.addEventListener('touchmove', onMove, { passive: false });
1359
  document.addEventListener('mouseup', onUp);
 
1364
  };
1365
 
1366
  document.getElementById('clearBtn').onclick = () => {
1367
+ const msg = CONFIG.twoPageMode ? "Clear all boxes on both pages?" : "Clear all boxes?";
1368
+ if (confirm(msg)) {
1369
  boxes = [];
1370
  selectedBoxIndex = -1;
1371
  saveBoxes();
1372
  drawBoxes();
1373
  els.toolbar.style.display = 'none';
1374
+
1375
+ if (CONFIG.hasRightPage) {
1376
+ boxesRight = [];
1377
+ selectedBoxIndexRight = -1;
1378
+ saveBoxesRight();
1379
+ drawBoxesRight();
1380
+ if (els.rightToolbar) els.rightToolbar.style.display = 'none';
1381
+ }
1382
  }
1383
  };
1384
 
1385
  document.getElementById('delete-btn').onclick = (e) => {
1386
  e.stopPropagation();
1387
+ if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
1388
+ boxesRight.splice(selectedBoxIndexRight, 1);
1389
+ selectedBoxIndexRight = -1;
1390
+ if (els.rightToolbar) els.rightToolbar.style.display = 'none';
1391
+ saveBoxesRight();
1392
+ drawBoxesRight();
1393
+ } else {
1394
+ boxes.splice(selectedBoxIndex, 1);
1395
+ selectedBoxIndex = -1;
1396
+ els.toolbar.style.display = 'none';
1397
+ saveBoxes();
1398
+ drawBoxes();
1399
+ }
1400
  };
1401
+
1402
  document.getElementById('move-up-btn').onclick = (e) => {
1403
  e.stopPropagation();
1404
+ if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
1405
+ if (selectedBoxIndexRight < boxesRight.length - 1) {
1406
+ const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
1407
+ boxesRight.splice(selectedBoxIndexRight + 1, 0, b);
1408
+ selectedBoxIndexRight++;
1409
+ saveBoxesRight();
1410
+ drawBoxesRight();
1411
+ updateToolbar();
1412
+ }
1413
+ } else if (selectedBoxIndex < boxes.length - 1) {
1414
  const b = boxes.splice(selectedBoxIndex, 1)[0];
1415
  boxes.splice(selectedBoxIndex + 1, 0, b);
1416
  selectedBoxIndex++;
 
1419
  updateToolbar();
1420
  }
1421
  };
1422
+
1423
  document.getElementById('move-down-btn').onclick = (e) => {
1424
  e.stopPropagation();
1425
+ if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
1426
+ if (selectedBoxIndexRight > 0) {
1427
+ const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
1428
+ boxesRight.splice(selectedBoxIndexRight - 1, 0, b);
1429
+ selectedBoxIndexRight--;
1430
+ saveBoxesRight();
1431
+ drawBoxesRight();
1432
+ updateToolbar();
1433
+ }
1434
+ } else if (selectedBoxIndex > 0) {
1435
  const b = boxes.splice(selectedBoxIndex, 1)[0];
1436
  boxes.splice(selectedBoxIndex - 1, 0, b);
1437
  selectedBoxIndex--;
 
1440
  updateToolbar();
1441
  }
1442
  };
1443
+
1444
  document.getElementById('stitch-btn').onclick = handleStitch;
1445
  document.getElementById('processBtn').onclick = processPage;
1446
 
1447
+ // Setup listeners for right page toolbar buttons
1448
+ if (CONFIG.hasRightPage) {
1449
+ document.getElementById('delete-btn-right').onclick = (e) => {
1450
+ e.stopPropagation();
1451
+ boxesRight.splice(selectedBoxIndexRight, 1);
1452
+ selectedBoxIndexRight = -1;
1453
+ els.rightToolbar.style.display = 'none';
1454
+ saveBoxesRight();
1455
+ drawBoxesRight();
1456
+ };
1457
+
1458
+ document.getElementById('move-up-btn-right').onclick = (e) => {
1459
+ e.stopPropagation();
1460
+ if (selectedBoxIndexRight < boxesRight.length - 1) {
1461
+ const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
1462
+ boxesRight.splice(selectedBoxIndexRight + 1, 0, b);
1463
+ selectedBoxIndexRight++;
1464
+ saveBoxesRight();
1465
+ drawBoxesRight();
1466
+ updateToolbar();
1467
+ }
1468
+ };
1469
+
1470
+ document.getElementById('move-down-btn-right').onclick = (e) => {
1471
+ e.stopPropagation();
1472
+ if (selectedBoxIndexRight > 0) {
1473
+ const b = boxesRight.splice(selectedBoxIndexRight, 1)[0];
1474
+ boxesRight.splice(selectedBoxIndexRight - 1, 0, b);
1475
+ selectedBoxIndexRight--;
1476
+ saveBoxesRight();
1477
+ drawBoxesRight();
1478
+ updateToolbar();
1479
+ }
1480
+ };
1481
+
1482
+ document.getElementById('stitch-btn-right').onclick = handleStitch;
1483
+ }
1484
+
1485
  // Data Entry
1486
  document.getElementById('dataToggle').onclick = () => {
1487
  const panel = document.getElementById('dataPanel');
 
1506
  'box-marked': 'marked_solution',
1507
  'box-actual': 'actual_solution'
1508
  };
1509
+
1510
  Object.keys(dataFields).forEach(id => {
1511
  document.getElementById(id).addEventListener('input', (e) => {
1512
+ if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
1513
+ boxesRight[selectedBoxIndexRight][dataFields[id]] = e.target.value;
1514
+ saveBoxesRight();
1515
+ } else if (selectedBoxIndex > -1) {
1516
  boxes[selectedBoxIndex][dataFields[id]] = e.target.value;
1517
  saveBoxes();
1518
  }
 
1598
  }
1599
 
1600
  function updateDataPanel() {
1601
+ // Get the selected box from either pane
1602
+ let b = null;
1603
+ if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
1604
+ b = boxesRight[selectedBoxIndexRight];
1605
+ } else if (selectedBoxIndex > -1) {
1606
+ b = boxes[selectedBoxIndex];
1607
+ }
1608
+
1609
  const msg = document.getElementById('no-selection-msg');
1610
  const form = document.getElementById('data-form');
1611
+
1612
  if (!b) {
1613
  if (msg) msg.style.display = 'block';
1614
  if (form) form.style.display = 'none';
1615
  return;
1616
  }
1617
+
1618
  if (msg) msg.style.display = 'none';
1619
  if (form) form.style.display = 'block';
1620
 
 
1626
 
1627
  function updateToolbar() {
1628
  updateDataPanel();
1629
+
1630
+ // Handle left toolbar
1631
+ if (selectedBoxIndex === -1 || activePane !== 'left') {
1632
  els.toolbar.classList.remove('show');
1633
  setTimeout(() => { if (!els.toolbar.classList.contains('show')) els.toolbar.style.display = 'none'; }, 200);
1634
+ } else {
1635
+ const b = boxes[selectedBoxIndex];
1636
+ const p = (pt) => ({ x: pt.x * els.canvas.width, y: pt.y * els.canvas.height });
1637
+ const maxX = Math.max(p(b.tr).x, p(b.br).x);
1638
+ const minY = Math.min(p(b.tl).y, p(b.tr).y);
1639
+ let left = maxX - 180;
1640
+ if (left < 0) left = 0;
1641
+ let top = minY + 10;
1642
+ els.toolbar.style.left = `${left}px`;
1643
+ els.toolbar.style.top = `${top}px`;
1644
+ els.toolbar.style.display = 'flex';
1645
+ requestAnimationFrame(() => els.toolbar.classList.add('show'));
1646
+ }
1647
+
1648
+ // Handle right toolbar
1649
+ if (CONFIG.hasRightPage && els.rightToolbar) {
1650
+ if (selectedBoxIndexRight === -1 || activePane !== 'right') {
1651
+ els.rightToolbar.classList.remove('show');
1652
+ setTimeout(() => { if (!els.rightToolbar.classList.contains('show')) els.rightToolbar.style.display = 'none'; }, 200);
1653
+ } else {
1654
+ const b = boxesRight[selectedBoxIndexRight];
1655
+ const p = (pt) => ({ x: pt.x * els.rightCanvas.width, y: pt.y * els.rightCanvas.height });
1656
+ const maxX = Math.max(p(b.tr).x, p(b.br).x);
1657
+ const minY = Math.min(p(b.tl).y, p(b.tr).y);
1658
+ let left = maxX - 180;
1659
+ if (left < 0) left = 0;
1660
+ let top = minY + 10;
1661
+ els.rightToolbar.style.left = `${left}px`;
1662
+ els.rightToolbar.style.top = `${top}px`;
1663
+ els.rightToolbar.style.display = 'flex';
1664
+ requestAnimationFrame(() => els.rightToolbar.classList.add('show'));
1665
+ }
1666
  }
 
 
 
 
 
 
 
 
 
 
 
1667
  }
1668
 
1669
  function updateStitchButton() {
1670
  const btn = document.getElementById('stitch-btn');
1671
  const icon = btn.querySelector('i');
1672
  const isBuffer = stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId;
1673
+
1674
+ // Check if current selection is stitched (either left or right page)
1675
+ let isStitched = false;
1676
+ if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
1677
+ isStitched = boxesRight[selectedBoxIndexRight]?.remote_stitch_source;
1678
+ } else if (selectedBoxIndex > -1) {
1679
+ isStitched = boxes[selectedBoxIndex]?.remote_stitch_source;
1680
+ }
1681
+
1682
  if (isBuffer) {
1683
  icon.className = 'bi bi-link-45deg';
1684
  btn.style.color = '#0dcaf0';
 
1689
  icon.className = 'bi bi-scissors';
1690
  btn.style.color = '#e9ecef';
1691
  }
1692
+
1693
+ // Update right stitch button too if present
1694
+ if (CONFIG.hasRightPage) {
1695
+ const btnRight = document.getElementById('stitch-btn-right');
1696
+ if (btnRight) {
1697
+ const iconRight = btnRight.querySelector('i');
1698
+ const isStitchedRight = selectedBoxIndexRight > -1 && boxesRight[selectedBoxIndexRight]?.remote_stitch_source;
1699
+ if (isBuffer) {
1700
+ iconRight.className = 'bi bi-link-45deg';
1701
+ btnRight.style.color = '#0dcaf0';
1702
+ } else if (isStitchedRight) {
1703
+ iconRight.className = 'bi bi-x-lg';
1704
+ btnRight.style.color = '#dc3545';
1705
+ } else {
1706
+ iconRight.className = 'bi bi-scissors';
1707
+ btnRight.style.color = '#e9ecef';
1708
+ }
1709
+ }
1710
+ }
1711
  }
1712
 
1713
  function handleStitch(e) {
1714
  e.stopPropagation();
1715
+
1716
+ // Determine which box is selected
1717
+ let b, currentBoxes, currentPageIndex, saveFunc, drawFunc;
1718
+ if (activePane === 'right' && selectedBoxIndexRight > -1 && CONFIG.hasRightPage) {
1719
+ b = boxesRight[selectedBoxIndexRight];
1720
+ currentBoxes = boxesRight;
1721
+ currentPageIndex = CONFIG.rightPageIndex;
1722
+ saveFunc = saveBoxesRight;
1723
+ drawFunc = drawBoxesRight;
1724
+ } else if (selectedBoxIndex > -1) {
1725
+ b = boxes[selectedBoxIndex];
1726
+ currentBoxes = boxes;
1727
+ currentPageIndex = CONFIG.leftPageIndex;
1728
+ saveFunc = saveBoxes;
1729
+ drawFunc = drawBoxes;
1730
+ } else {
1731
+ return;
1732
+ }
1733
+
1734
  if (stitchBuffer && stitchBuffer.session_id === CONFIG.sessionId) {
1735
  b.remote_stitch_source = {
1736
  page_index: stitchBuffer.page_index,
 
1748
  const cleanBox = { ...b, x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1749
  stitchBuffer = {
1750
  session_id: CONFIG.sessionId,
1751
+ page_index: currentPageIndex,
1752
  box: cleanBox
1753
  };
1754
  localStorage.setItem('gemini_stitch_buffer', JSON.stringify(stitchBuffer));
1755
  toast('Copied!');
1756
  }
1757
+ saveFunc();
1758
  updateStitchButton();
1759
  drawBoxes();
1760
+ if (CONFIG.hasRightPage) drawBoxesRight();
1761
  }
1762
 
1763
  function toast(msg) {
 
1769
  }
1770
 
1771
  async function processPage() {
1772
+ const hasLeftBoxes = boxes.length > 0;
1773
+ const hasRightBoxes = CONFIG.hasRightPage && boxesRight.length > 0;
1774
+
1775
+ if (!hasLeftBoxes && !hasRightBoxes) {
1776
  // Show non-intrusive toast and continue
1777
+ toast('Skipping page(s)...');
1778
  await new Promise(r => setTimeout(r, 300)); // Brief delay for toast visibility
1779
  }
1780
 
1781
+ ProgressBar.show(0.2);
1782
  document.getElementById('loader-overlay').style.display = 'flex';
1783
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1784
  try {
1785
+ // Process left page
1786
+ if (hasLeftBoxes || !CONFIG.twoPageMode) {
1787
+ const finalBoxes = boxes.map(b => ({
1788
+ ...b,
1789
+ x: Math.min(b.tl.x, b.bl.x),
1790
+ y: Math.min(b.tl.y, b.tr.y),
1791
+ w: Math.max(b.tr.x, b.br.x) - Math.min(b.tl.x, b.bl.x),
1792
+ h: Math.max(b.bl.y, b.br.y) - Math.min(b.tl.y, b.tr.y)
1793
+ }));
1794
+
1795
+ const cv = document.createElement('canvas');
1796
+ cv.width = els.image.naturalWidth;
1797
+ cv.height = els.image.naturalHeight;
1798
+ const c = cv.getContext('2d');
1799
+ c.filter = els.image.style.filter;
1800
+ c.drawImage(els.image, 0, 0);
1801
+
1802
+ ProgressBar.update(0.4);
1803
+
1804
+ const res = await fetch('/process_crop_v2', {
1805
+ method: 'POST',
1806
+ headers: { 'Content-Type': 'application/json' },
1807
+ body: JSON.stringify({
1808
+ session_id: CONFIG.sessionId,
1809
+ image_index: CONFIG.leftPageIndex,
1810
+ boxes: finalBoxes,
1811
+ imageData: cv.toDataURL('image/jpeg', 0.85)
1812
+ })
1813
+ });
1814
+
1815
+ if (!res.ok) throw new Error(await res.text());
1816
+ }
1817
+
1818
+ ProgressBar.update(0.6);
1819
+
1820
+ // Process right page if in two-page mode
1821
+ if (CONFIG.hasRightPage && (hasRightBoxes || CONFIG.twoPageMode)) {
1822
+ const finalBoxesRight = boxesRight.map(b => ({
1823
+ ...b,
1824
+ x: Math.min(b.tl.x, b.bl.x),
1825
+ y: Math.min(b.tl.y, b.tr.y),
1826
+ w: Math.max(b.tr.x, b.br.x) - Math.min(b.tl.x, b.bl.x),
1827
+ h: Math.max(b.bl.y, b.br.y) - Math.min(b.tl.y, b.tr.y)
1828
+ }));
1829
+
1830
+ const cvRight = document.createElement('canvas');
1831
+ cvRight.width = els.rightImage.naturalWidth;
1832
+ cvRight.height = els.rightImage.naturalHeight;
1833
+ const cRight = cvRight.getContext('2d');
1834
+ cRight.filter = els.rightImage.style.filter;
1835
+ cRight.drawImage(els.rightImage, 0, 0);
1836
+
1837
+ const resRight = await fetch('/process_crop_v2', {
1838
+ method: 'POST',
1839
+ headers: { 'Content-Type': 'application/json' },
1840
+ body: JSON.stringify({
1841
+ session_id: CONFIG.sessionId,
1842
+ image_index: CONFIG.rightPageIndex,
1843
+ boxes: finalBoxesRight,
1844
+ imageData: cvRight.toDataURL('image/jpeg', 0.85)
1845
+ })
1846
+ });
1847
+
1848
+ if (!resRight.ok) throw new Error(await resRight.text());
1849
+ }
1850
+
1851
  ProgressBar.update(1);
1852
  setTimeout(() => {
1853
  const next = CONFIG.imageIndex + 1;
1854
+ location.href = next < CONFIG.totalPages
1855
+ ? `/cropv2/${CONFIG.sessionId}/${next}`
1856
  : `/question_entry_v2/${CONFIG.sessionId}`;
1857
  }, 200);
1858
  } catch (e) {
 
1865
  function saveBoxes() {
1866
  localStorage.setItem(storageKey, JSON.stringify(boxes));
1867
  }
1868
+
1869
+ function saveBoxesRight() {
1870
+ if (storageKeyRight) {
1871
+ localStorage.setItem(storageKeyRight, JSON.stringify(boxesRight));
1872
+ }
1873
+ }
1874
+
1875
  function loadBoxes() {
1876
  try {
1877
  const s = localStorage.getItem(storageKey);
 
1889
  } catch (e) {}
1890
  }
1891
 
1892
+ function loadBoxesRight() {
1893
+ if (!storageKeyRight) return;
1894
+ try {
1895
+ const s = localStorage.getItem(storageKeyRight);
1896
+ if (s) {
1897
+ boxesRight = JSON.parse(s).map(b => b.tl ? b : {
1898
+ id: b.id || Date.now(),
1899
+ tl: { x: b.x, y: b.y },
1900
+ tr: { x: b.x + b.w, y: b.y },
1901
+ bl: { x: b.x, y: b.y + b.h },
1902
+ br: { x: b.x + b.w, y: b.y + b.h },
1903
+ remote_stitch_source: b.remote_stitch_source
1904
+ });
1905
+ drawBoxesRight();
1906
+ }
1907
+ } catch (e) {}
1908
+ }
1909
+
1910
  function updateFilters() {
1911
  const b = document.getElementById('brightness').value;
1912
  const c = document.getElementById('contrast').value;
1913
  const g = document.getElementById('gamma').value;
1914
+
1915
  document.getElementById('val-b').innerText = b;
1916
  document.getElementById('val-c').innerText = c;
1917
  document.getElementById('val-g').innerText = g;
1918
+
1919
+ const filterValue = `brightness(${100 + parseFloat(b)}%) contrast(${c})`;
1920
+ els.image.style.filter = filterValue;
1921
+ els.magnifier.style.filter = filterValue;
1922
+
1923
+ // Apply filters to right page in two-page mode
1924
+ if (CONFIG.hasRightPage && els.rightImage) {
1925
+ els.rightImage.style.filter = filterValue;
1926
+ if (els.rightMagnifier) {
1927
+ els.rightMagnifier.style.filter = filterValue;
1928
+ }
1929
+ }
1930
+
1931
  localStorage.setItem('pdfFilters', JSON.stringify({ b, c, g }));
1932
  }
1933
 
templates/question_entry_v2.html CHANGED
@@ -100,77 +100,6 @@
100
  background: var(--border-subtle);
101
  }
102
 
103
- /* Notes Modal Styles */
104
- #notes-canvas-body {
105
- height: calc(100vh - 140px);
106
- }
107
- #question-reference-panel {
108
- min-width: 200px;
109
- max-width: 300px;
110
- }
111
- #notes-canvas-container {
112
- height: 100%;
113
- background: #f8f9fa;
114
- border: 1px solid var(--border-subtle);
115
- overflow: hidden;
116
- position: relative;
117
- display: flex;
118
- justify-content: center;
119
- align-items: center;
120
- touch-action: none;
121
- }
122
- #notes-canvas-container canvas {
123
- touch-action: none;
124
- }
125
- @media (max-width: 768px) {
126
- #question-reference-panel {
127
- display: none !important;
128
- }
129
- }
130
- .notes-toolbar {
131
- display: flex;
132
- gap: 8px;
133
- padding: 12px 16px;
134
- background: linear-gradient(180deg, var(--bg-card), var(--bg-dark));
135
- border-bottom: 1px solid var(--border-subtle);
136
- flex-wrap: wrap;
137
- align-items: center;
138
- }
139
- .notes-toolbar .tool-group {
140
- display: flex;
141
- gap: 4px;
142
- padding: 0 8px;
143
- border-right: 1px solid var(--border-subtle);
144
- }
145
- .notes-toolbar .tool-group:last-child {
146
- border-right: none;
147
- }
148
- .notes-toolbar .btn {
149
- min-width: 40px;
150
- height: 40px;
151
- display: flex;
152
- align-items: center;
153
- justify-content: center;
154
- border-radius: 8px;
155
- transition: all var(--transition-fast);
156
- }
157
- .notes-toolbar .btn:hover {
158
- transform: translateY(-2px);
159
- }
160
- .notes-toolbar .btn.active {
161
- box-shadow: 0 0 10px rgba(255,255,255,0.3);
162
- }
163
- .color-swatch {
164
- width: 28px;
165
- height: 28px;
166
- border-radius: 50%;
167
- cursor: pointer;
168
- border: 2px solid transparent;
169
- transition: all var(--transition-fast);
170
- }
171
- .color-swatch:hover { transform: scale(1.1); }
172
- .color-swatch.active { border-color: #fff; transform: scale(1.15); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
173
-
174
  /* --- UNIFIED NOTE CARD STYLES --- */
175
  .note-card {
176
  background: linear-gradient(135deg, rgba(13, 202, 240, 0.08), rgba(13, 202, 240, 0.15));
@@ -283,10 +212,11 @@
283
  </legend>
284
  <div class="col-md-3 mb-3 text-center">
285
  <img src="/image/processed/{{ session_id }}/{{ image.processed_filename }}" class="img-fluid rounded mb-2" alt="Cropped Question {{ loop.index }}">
286
- {% if image.note_filename %}
287
  <div class="note-card">
288
- <div class="position-relative">
289
- <img src="/processed/{{ image.note_filename }}" class="img-fluid note-thumbnail" alt="Note">
 
290
  </div>
291
  <div class="note-actions flex-wrap justify-content-center">
292
  <div class="form-check form-switch include-pdf-toggle">
@@ -295,7 +225,7 @@
295
  onchange="toggleNoteInPdf('{{ image.id }}', this.checked)">
296
  <label class="form-check-label small" for="include_note_{{ image.id }}">In PDF</label>
297
  </div>
298
- <button type="button" class="btn btn-sm btn-outline-info btn-pill" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}', '{{ image.note_filename }}')" title="Edit Note">
299
  <i class="bi bi-pencil"></i>
300
  </button>
301
  <button type="button" class="btn btn-sm btn-outline-danger btn-pill" onclick="deleteNote('{{ image.id }}')" title="Delete Note">
@@ -304,7 +234,7 @@
304
  </div>
305
  </div>
306
  {% else %}
307
- <button type="button" class="btn btn-sm btn-outline-info btn-pill w-100" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}', '')">
308
  <i class="bi bi-pencil-square me-1"></i>Add Revision Notes
309
  </button>
310
  {% endif %}
@@ -451,108 +381,8 @@
451
  </div>
452
  </div>
453
 
454
- <!-- Notes Modal -->
455
- <div class="modal fade" id="notesModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
456
- <div class="modal-dialog modal-fullscreen">
457
- <div class="modal-content bg-dark text-white">
458
- <div class="modal-header py-2 border-secondary" style="background: linear-gradient(180deg, var(--bg-card), var(--bg-dark));">
459
- <h5 class="modal-title"><i class="bi bi-pencil-fill me-2"></i>Add Revision Notes</h5>
460
- <div class="d-flex gap-2">
461
- <button class="btn btn-success btn-pill px-3" onclick="saveNotes()">
462
- <i class="bi bi-check-lg me-1"></i>Save Notes
463
- </button>
464
- <button type="button" class="btn btn-outline-secondary btn-pill" data-bs-dismiss="modal">
465
- <i class="bi bi-x-lg me-1"></i>Cancel
466
- </button>
467
- </div>
468
- </div>
469
- <div class="notes-toolbar">
470
- <!-- Drawing Tools -->
471
- <div class="tool-group">
472
- <button class="btn btn-outline-light active" id="tool-pencil" onclick="setTool('pencil')" title="Pencil (P)">
473
- <i class="bi bi-pencil"></i>
474
- </button>
475
- <button class="btn btn-outline-light" id="tool-highlighter" onclick="setTool('highlighter')" title="Highlighter (H)">
476
- <i class="bi bi-highlighter"></i>
477
- </button>
478
- <button class="btn btn-outline-light" id="tool-select" onclick="setTool('select')" title="Select (S)">
479
- <i class="bi bi-cursor"></i>
480
- </button>
481
- </div>
482
-
483
- <!-- Colors -->
484
- <div class="tool-group">
485
- <div class="color-swatch active" style="background: #000000;" data-color="#000000" onclick="setColor('#000000')" title="Black"></div>
486
- <div class="color-swatch" style="background: #dc3545;" data-color="#dc3545" onclick="setColor('#dc3545')" title="Red"></div>
487
- <div class="color-swatch" style="background: #0d6efd;" data-color="#0d6efd" onclick="setColor('#0d6efd')" title="Blue"></div>
488
- <div class="color-swatch" style="background: #198754;" data-color="#198754" onclick="setColor('#198754')" title="Green"></div>
489
- <div class="color-swatch" style="background: #ffc107;" data-color="#ffc107" onclick="setColor('#ffc107')" title="Yellow"></div>
490
- <div class="color-swatch" style="background: #6f42c1;" data-color="#6f42c1" onclick="setColor('#6f42c1')" title="Purple"></div>
491
- </div>
492
-
493
- <!-- Brush Size -->
494
- <div class="tool-group">
495
- <label class="text-muted small me-1 d-flex align-items-center">Size:</label>
496
- <input type="range" class="form-range" style="width: 80px;" min="1" max="30" value="3" id="brush-size" oninput="setBrushSize(this.value)">
497
- <span class="text-white small ms-1" id="brush-size-val">3</span>
498
- </div>
499
-
500
- <!-- Shapes -->
501
- <div class="tool-group">
502
- <button class="btn btn-outline-light" onclick="addShape('rect')" title="Rectangle">
503
- <i class="bi bi-square"></i>
504
- </button>
505
- <button class="btn btn-outline-light" onclick="addShape('circle')" title="Circle">
506
- <i class="bi bi-circle"></i>
507
- </button>
508
- <button class="btn btn-outline-light" onclick="addShape('arrow')" title="Arrow">
509
- <i class="bi bi-arrow-up-right"></i>
510
- </button>
511
- <button class="btn btn-outline-light" onclick="addText()" title="Add Text (T)">
512
- <i class="bi bi-fonts"></i>
513
- </button>
514
- </div>
515
-
516
- <!-- Actions -->
517
- <div class="tool-group">
518
- <button class="btn btn-outline-warning" onclick="undoCanvas()" title="Undo (Ctrl+Z)">
519
- <i class="bi bi-arrow-counterclockwise"></i>
520
- </button>
521
- <button class="btn btn-outline-warning" onclick="redoCanvas()" title="Redo (Ctrl+Y)">
522
- <i class="bi bi-arrow-clockwise"></i>
523
- </button>
524
- <button class="btn btn-outline-danger" onclick="deleteSelected()" title="Delete Selected (Del)">
525
- <i class="bi bi-trash"></i>
526
- </button>
527
- <button class="btn btn-outline-danger" onclick="clearCanvas()" title="Clear All">
528
- <i class="bi bi-x-circle"></i>
529
- </button>
530
- </div>
531
-
532
- <!-- Pen Only Mode -->
533
- <div class="tool-group border-0">
534
- <div class="form-check form-switch">
535
- <input class="form-check-input" type="checkbox" id="pen-only-mode" onchange="togglePenOnly()">
536
- <label class="form-check-label small" for="pen-only-mode">Stylus Only</label>
537
- </div>
538
- </div>
539
- </div>
540
- <div class="modal-body p-0 d-flex" id="notes-canvas-body">
541
- <!-- Question Reference Panel -->
542
- <div id="question-reference-panel" class="bg-secondary" style="width: 250px; flex-shrink: 0; overflow: auto; border-right: 2px solid #495057;">
543
- <div class="p-2 text-center">
544
- <small class="text-white-50">Question Reference</small>
545
- <img id="notes-question-ref" src="" class="img-fluid rounded mt-2" style="max-height: 80vh; object-fit: contain;" alt="Question">
546
- </div>
547
- </div>
548
- <!-- Canvas Area -->
549
- <div id="notes-canvas-container" style="flex: 1;">
550
- <canvas id="notes-canvas"></canvas>
551
- </div>
552
- </div>
553
- </div>
554
- </div>
555
- </div>
556
 
557
  <div class="accordion mt-4" id="misc-accordion">
558
  <div class="accordion-item bg-dark">
@@ -758,16 +588,6 @@
758
  const sessionId = '{{ session_id }}';
759
  let answerKeyData = new Map();
760
  let miscellaneousQuestions = [];
761
-
762
- // Canvas Variables
763
- let canvas;
764
- let currentNoteImageId = null;
765
- let penColor = '#000000';
766
- let penWidth = 3;
767
- let isPenOnly = false;
768
- let canvasHistory = [];
769
- let historyIndex = -1;
770
- let isHistoryAction = false;
771
 
772
  async function initializeTomSelect() {
773
  try {
@@ -795,377 +615,6 @@
795
  console.error('Error initializing Tom Select:', err);
796
  }
797
  }
798
-
799
- // --- CANVAS FUNCTIONS ---
800
- let currentQuestionImageUrl = null;
801
-
802
- function openNotesModal(imageId, imageUrl, existingNoteUrl) {
803
- currentNoteImageId = imageId;
804
- currentQuestionImageUrl = imageUrl;
805
- canvasHistory = [];
806
- historyIndex = -1;
807
-
808
- // Set the question reference image
809
- document.getElementById('notes-question-ref').src = imageUrl;
810
-
811
- const modal = new bootstrap.Modal(document.getElementById('notesModal'));
812
- modal.show();
813
-
814
- document.getElementById('notesModal').addEventListener('shown.bs.modal', function init() {
815
- initCanvas(existingNoteUrl);
816
- document.getElementById('notesModal').removeEventListener('shown.bs.modal', init);
817
- });
818
- }
819
-
820
- function initCanvas(existingNoteUrl) {
821
- // Dispose existing canvas if any
822
- if (canvas) {
823
- canvas.dispose();
824
- }
825
-
826
- const container = document.getElementById('notes-canvas-container');
827
- const maxWidth = container.clientWidth;
828
- const maxHeight = container.clientHeight;
829
-
830
- // Create canvas with a blank white background (separate revision notes)
831
- canvas = new fabric.Canvas('notes-canvas', {
832
- isDrawingMode: true,
833
- selection: false,
834
- preserveObjectStacking: true,
835
- width: maxWidth,
836
- height: maxHeight,
837
- backgroundColor: '#ffffff' // White background for handwritten notes
838
- });
839
-
840
- // Set initial brush
841
- setTool('pencil');
842
-
843
- // Save state after each modification (debounced to avoid too many saves)
844
- let saveTimeout;
845
- const debouncedSave = () => {
846
- clearTimeout(saveTimeout);
847
- saveTimeout = setTimeout(saveCanvasState, 100);
848
- };
849
- canvas.on('path:created', debouncedSave);
850
- canvas.on('object:modified', debouncedSave);
851
- canvas.on('object:removed', saveCanvasState);
852
-
853
- // Better touch/stylus handling
854
- canvas.on('mouse:down', function(opt) {
855
- // Palm rejection: disable drawing for touch when stylus-only mode is on
856
- if (isPenOnly && opt.e.pointerType !== 'pen') {
857
- canvas.isDrawingMode = false;
858
- }
859
- });
860
-
861
- canvas.on('mouse:up', function(opt) {
862
- // Re-enable drawing mode after palm rejection
863
- if (isPenOnly) {
864
- const tool = document.querySelector('.notes-toolbar .btn.active')?.id;
865
- if (tool === 'tool-pencil' || tool === 'tool-highlighter') {
866
- canvas.isDrawingMode = true;
867
- }
868
- }
869
- });
870
-
871
- // Keyboard shortcuts
872
- document.addEventListener('keydown', handleCanvasKeyboard);
873
-
874
- // Load existing note if present
875
- if (existingNoteUrl) {
876
- fabric.Image.fromURL('/processed/' + existingNoteUrl, function(noteImg) {
877
- // Scale to fit while maintaining aspect ratio
878
- const scale = Math.min(maxWidth / noteImg.width, maxHeight / noteImg.height, 1);
879
- noteImg.scale(scale);
880
- noteImg.set({
881
- left: (maxWidth - noteImg.width * scale) / 2,
882
- top: (maxHeight - noteImg.height * scale) / 2,
883
- selectable: true,
884
- evented: true
885
- });
886
- canvas.add(noteImg);
887
- canvas.renderAll();
888
- saveCanvasState();
889
- }, { crossOrigin: 'anonymous' });
890
- } else {
891
- canvas.renderAll();
892
- saveCanvasState();
893
- }
894
- }
895
-
896
- function handleCanvasKeyboard(e) {
897
- if (!document.getElementById('notesModal').classList.contains('show')) return;
898
-
899
- // Ctrl+Z: Undo
900
- if (e.ctrlKey && e.key === 'z') {
901
- e.preventDefault();
902
- undoCanvas();
903
- return;
904
- }
905
-
906
- // Ctrl+Y: Redo
907
- if (e.ctrlKey && e.key === 'y') {
908
- e.preventDefault();
909
- redoCanvas();
910
- return;
911
- }
912
-
913
- // Delete/Backspace: Delete selected
914
- if (e.key === 'Delete' || e.key === 'Backspace') {
915
- if (document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
916
- e.preventDefault();
917
- deleteSelected();
918
- }
919
- return;
920
- }
921
-
922
- // Tool shortcuts
923
- if (e.key === 'p') setTool('pencil');
924
- if (e.key === 'h') setTool('highlighter');
925
- if (e.key === 's') setTool('select');
926
- if (e.key === 't') addText();
927
- }
928
-
929
- function saveCanvasState() {
930
- if (isHistoryAction) return;
931
-
932
- // Remove any states after current index
933
- canvasHistory = canvasHistory.slice(0, historyIndex + 1);
934
-
935
- // Save current state
936
- const state = canvas.toJSON(['selectable', 'evented']);
937
- canvasHistory.push(state);
938
- historyIndex++;
939
-
940
- // Limit history size
941
- if (canvasHistory.length > 50) {
942
- canvasHistory.shift();
943
- historyIndex--;
944
- }
945
- }
946
-
947
- function undoCanvas() {
948
- if (historyIndex <= 0) return;
949
-
950
- isHistoryAction = true;
951
- historyIndex--;
952
-
953
- const bg = canvas.backgroundImage;
954
- canvas.loadFromJSON(canvasHistory[historyIndex], function() {
955
- canvas.setBackgroundImage(bg, canvas.renderAll.bind(canvas));
956
- isHistoryAction = false;
957
- });
958
- }
959
-
960
- function redoCanvas() {
961
- if (historyIndex >= canvasHistory.length - 1) return;
962
-
963
- isHistoryAction = true;
964
- historyIndex++;
965
-
966
- const bg = canvas.backgroundImage;
967
- canvas.loadFromJSON(canvasHistory[historyIndex], function() {
968
- canvas.setBackgroundImage(bg, canvas.renderAll.bind(canvas));
969
- isHistoryAction = false;
970
- });
971
- }
972
-
973
- function setTool(tool) {
974
- if (!canvas) return;
975
-
976
- document.querySelectorAll('.notes-toolbar .btn').forEach(b => b.classList.remove('active'));
977
-
978
- if (tool === 'pencil') {
979
- document.getElementById('tool-pencil').classList.add('active');
980
- canvas.isDrawingMode = true;
981
- canvas.selection = false;
982
- canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
983
- canvas.freeDrawingBrush.color = penColor;
984
- canvas.freeDrawingBrush.width = parseInt(penWidth, 10);
985
- } else if (tool === 'highlighter') {
986
- document.getElementById('tool-highlighter').classList.add('active');
987
- canvas.isDrawingMode = true;
988
- canvas.selection = false;
989
- canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
990
- // Highlighter: larger, semi-transparent
991
- canvas.freeDrawingBrush.color = hexToRgba(penColor, 0.4);
992
- canvas.freeDrawingBrush.width = parseInt(penWidth, 10) * 4;
993
- } else if (tool === 'select') {
994
- document.getElementById('tool-select').classList.add('active');
995
- canvas.isDrawingMode = false;
996
- canvas.selection = true;
997
- // Make all objects selectable
998
- canvas.getObjects().forEach(obj => {
999
- obj.selectable = true;
1000
- obj.evented = true;
1001
- });
1002
- }
1003
- }
1004
-
1005
- function hexToRgba(hex, alpha) {
1006
- const r = parseInt(hex.slice(1, 3), 16);
1007
- const g = parseInt(hex.slice(3, 5), 16);
1008
- const b = parseInt(hex.slice(5, 7), 16);
1009
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
1010
- }
1011
-
1012
- function setColor(color) {
1013
- penColor = color;
1014
-
1015
- document.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
1016
- document.querySelector(`.color-swatch[data-color="${color}"]`)?.classList.add('active');
1017
-
1018
- if (canvas && canvas.freeDrawingBrush) {
1019
- const activeTool = document.querySelector('.notes-toolbar .btn.active')?.id;
1020
- if (activeTool === 'tool-highlighter') {
1021
- canvas.freeDrawingBrush.color = hexToRgba(color, 0.4);
1022
- } else {
1023
- canvas.freeDrawingBrush.color = color;
1024
- }
1025
- }
1026
- }
1027
-
1028
- function setBrushSize(size) {
1029
- penWidth = size;
1030
- document.getElementById('brush-size-val').textContent = size;
1031
-
1032
- if (canvas && canvas.freeDrawingBrush) {
1033
- const activeTool = document.querySelector('.notes-toolbar .btn.active')?.id;
1034
- if (activeTool === 'tool-highlighter') {
1035
- canvas.freeDrawingBrush.width = parseInt(size, 10) * 4;
1036
- } else {
1037
- canvas.freeDrawingBrush.width = parseInt(size, 10);
1038
- }
1039
- }
1040
- }
1041
-
1042
- function deleteSelected() {
1043
- if (!canvas) return;
1044
- const active = canvas.getActiveObjects();
1045
- if (active.length > 0) {
1046
- active.forEach(obj => canvas.remove(obj));
1047
- canvas.discardActiveObject();
1048
- canvas.renderAll();
1049
- }
1050
- }
1051
-
1052
- function clearCanvas() {
1053
- if (!canvas) return;
1054
- if (confirm("Clear all notes? This cannot be undone.")) {
1055
- canvas.clear();
1056
- canvas.backgroundColor = '#ffffff';
1057
- canvas.renderAll();
1058
- saveCanvasState();
1059
- }
1060
- }
1061
-
1062
- function addShape(shape) {
1063
- if (!canvas) return;
1064
- setTool('select');
1065
-
1066
- let obj;
1067
- const centerX = canvas.width / 2 - 50;
1068
- const centerY = canvas.height / 2 - 50;
1069
-
1070
- if (shape === 'rect') {
1071
- obj = new fabric.Rect({
1072
- left: centerX, top: centerY,
1073
- fill: 'transparent',
1074
- stroke: penColor,
1075
- strokeWidth: parseInt(penWidth, 10),
1076
- width: 100, height: 60
1077
- });
1078
- } else if (shape === 'circle') {
1079
- obj = new fabric.Circle({
1080
- left: centerX, top: centerY,
1081
- fill: 'transparent',
1082
- stroke: penColor,
1083
- strokeWidth: parseInt(penWidth, 10),
1084
- radius: 40
1085
- });
1086
- } else if (shape === 'arrow') {
1087
- // Create an arrow using a line and triangle
1088
- const line = new fabric.Line([centerX, centerY + 50, centerX + 80, centerY], {
1089
- stroke: penColor,
1090
- strokeWidth: parseInt(penWidth, 10),
1091
- selectable: false
1092
- });
1093
- const triangle = new fabric.Triangle({
1094
- left: centerX + 80, top: centerY - 8,
1095
- fill: penColor,
1096
- width: 16, height: 16,
1097
- angle: 45,
1098
- selectable: false
1099
- });
1100
- obj = new fabric.Group([line, triangle], {
1101
- left: centerX, top: centerY
1102
- });
1103
- }
1104
-
1105
- if (obj) {
1106
- canvas.add(obj);
1107
- canvas.setActiveObject(obj);
1108
- }
1109
- }
1110
-
1111
- function addText() {
1112
- if (!canvas) return;
1113
- setTool('select');
1114
-
1115
- const text = new fabric.IText('Type here...', {
1116
- left: canvas.width / 2 - 50,
1117
- top: canvas.height / 2 - 10,
1118
- fontFamily: 'Arial, sans-serif',
1119
- fill: penColor,
1120
- fontSize: 18 + (parseInt(penWidth, 10) * 2)
1121
- });
1122
-
1123
- canvas.add(text);
1124
- canvas.setActiveObject(text);
1125
- text.selectAll();
1126
- text.enterEditing();
1127
- }
1128
-
1129
- function togglePenOnly() {
1130
- isPenOnly = document.getElementById('pen-only-mode').checked;
1131
- }
1132
-
1133
- async function toggleNoteInPdf(imageId, include) {
1134
- try {
1135
- const response = await fetch('/toggle_note_in_pdf', {
1136
- method: 'POST',
1137
- headers: { 'Content-Type': 'application/json' },
1138
- body: JSON.stringify({ image_id: imageId, include: include })
1139
- });
1140
- const result = await response.json();
1141
- if (!result.success) {
1142
- showStatus('Failed to update setting: ' + result.error, 'danger');
1143
- }
1144
- } catch (e) {
1145
- showStatus('Error: ' + e.message, 'danger');
1146
- }
1147
- }
1148
-
1149
- async function deleteNote(imageId) {
1150
- if (!confirm('Delete this note? This cannot be undone.')) return;
1151
-
1152
- try {
1153
- const response = await fetch('/delete_note', {
1154
- method: 'POST',
1155
- headers: { 'Content-Type': 'application/json' },
1156
- body: JSON.stringify({ image_id: imageId })
1157
- });
1158
- const result = await response.json();
1159
- if (result.success) {
1160
- showStatus('Note deleted', 'success');
1161
- location.reload();
1162
- } else {
1163
- showStatus('Failed to delete note: ' + result.error, 'danger');
1164
- }
1165
- } catch (e) {
1166
- showStatus('Error: ' + e.message, 'danger');
1167
- }
1168
- }
1169
 
1170
  // --- AUTO-SAVE FUNCTIONALITY ---
1171
  let autoSaveTimeout = null;
@@ -1279,49 +728,6 @@
1279
  autoSaveQuestion(fieldset);
1280
  }
1281
 
1282
- async function saveNotes() {
1283
- if (!canvas || !currentNoteImageId) return;
1284
-
1285
- // Export the entire canvas with white background
1286
- const dataUrl = canvas.toDataURL({
1287
- format: 'png',
1288
- multiplier: 2 // Higher quality
1289
- });
1290
-
1291
- // Convert data URL to blob
1292
- const response = await fetch(dataUrl);
1293
- const blob = await response.blob();
1294
-
1295
- const formData = new FormData();
1296
- formData.append('image', blob, 'note.png');
1297
- formData.append('image_id', currentNoteImageId);
1298
- formData.append('session_id', sessionId);
1299
-
1300
- try {
1301
- showStatus('Saving notes...', 'info');
1302
- const res = await fetch('/save_note_image', {
1303
- method: 'POST',
1304
- body: formData
1305
- });
1306
- const result = await res.json();
1307
-
1308
- if (result.success) {
1309
- showStatus('Notes saved!', 'success');
1310
-
1311
- // Close modal
1312
- const modal = bootstrap.Modal.getInstance(document.getElementById('notesModal'));
1313
- modal.hide();
1314
-
1315
- // Reload page to show updated note
1316
- location.reload();
1317
- } else {
1318
- showStatus('Error saving notes: ' + result.error, 'danger');
1319
- }
1320
- } catch (e) {
1321
- showStatus('Error: ' + e.message, 'danger');
1322
- }
1323
- }
1324
-
1325
  function saveSettings() {
1326
  // Session-specific settings
1327
  const sessionSettings = {
@@ -1431,10 +837,18 @@
1431
  }
1432
 
1433
  function setupEventListeners() {
1434
- document.getElementById('json-upload').addEventListener('change', handleJsonUpload);
1435
- document.getElementById('images_per_page').addEventListener('change', updateGridDefaults);
1436
- document.getElementById('orientation').addEventListener('change', updateGridDefaults);
1437
- document.getElementById('practice_mode').addEventListener('change', handlePracticeModeChange);
 
 
 
 
 
 
 
 
1438
  document.querySelectorAll('input[id^="question_number_"]').forEach(input => {
1439
  input.addEventListener('change', handleQuestionNumberChange);
1440
  input.addEventListener('blur', () => autoSaveQuestion(input.closest('fieldset')));
@@ -1445,8 +859,12 @@
1445
  });
1446
  document.addEventListener('keydown', handleShortcuts);
1447
  document.querySelectorAll('.status-buttons').forEach(setupStatusButtons);
1448
- document.getElementById('questions-form').addEventListener('submit', handleFormSubmit);
1449
- document.getElementById('preview-btn').addEventListener('click', handlePreview);
 
 
 
 
1450
 
1451
  // Auto-save PDF metadata fields on blur
1452
  ['pdf_subject', 'pdf_tags', 'pdf_notes', 'pdf_name'].forEach(fieldId => {
@@ -1454,28 +872,30 @@
1454
  if (field) field.addEventListener('blur', autoSaveSessionMetadata);
1455
  });
1456
 
1457
- document.getElementById('add-misc-question').addEventListener('click', handleAddMiscQuestion);
1458
-
1459
- document.getElementById('extract-classify-all').addEventListener('click', async () => {
1460
- const extractBtn = document.getElementById('extract-classify-all');
1461
- const spinner = extractBtn.querySelector('.spinner-border');
1462
- const text = extractBtn.querySelector('.extract-text');
1463
-
1464
- spinner.classList.remove('d-none');
1465
- text.textContent = 'Processing...';
1466
- extractBtn.disabled = true;
1467
-
1468
- // First, save the questions
1469
- const questions = [];
1470
- document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => {
1471
- questions.push({
1472
- image_id: fieldset.querySelector('input[name^="image_id_"]').value,
1473
- question_number: fieldset.querySelector('input[name^="question_number_"]').value,
1474
- status: fieldset.querySelector('select[name^="status_"]').value,
1475
- marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value,
1476
- actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value
 
 
 
1477
  });
1478
- });
1479
 
1480
  try {
1481
  const saveResponse = await fetch('/save_questions', {
@@ -1513,6 +933,7 @@
1513
  extractBtn.disabled = false;
1514
  }
1515
  });
 
1516
 
1517
  // Add event listeners for auto-extract buttons if NVIDIA NIM is available
1518
  {% if nvidia_nim_available %}
@@ -1523,9 +944,10 @@
1523
  autoExtractQuestionNumber(this, imageId, index);
1524
  });
1525
  });
1526
-
1527
  // Add event listener for auto-extract all button
1528
- document.getElementById('auto-extract-all').addEventListener('click', autoExtractAllQuestionNumbers);
 
1529
 
1530
  // Add event listeners for delete buttons
1531
  document.querySelectorAll('.delete-question-btn').forEach(button => {
 
100
  background: var(--border-subtle);
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  /* --- UNIFIED NOTE CARD STYLES --- */
104
  .note-card {
105
  background: linear-gradient(135deg, rgba(13, 202, 240, 0.08), rgba(13, 202, 240, 0.15));
 
212
  </legend>
213
  <div class="col-md-3 mb-3 text-center">
214
  <img src="/image/processed/{{ session_id }}/{{ image.processed_filename }}" class="img-fluid rounded mb-2" alt="Cropped Question {{ loop.index }}">
215
+ {% if image.note_json %}
216
  <div class="note-card">
217
+ <div class="d-flex align-items-center justify-content-center gap-2 py-2 text-success">
218
+ <i class="bi bi-check-circle-fill"></i>
219
+ <span class="small">Notes saved</span>
220
  </div>
221
  <div class="note-actions flex-wrap justify-content-center">
222
  <div class="form-check form-switch include-pdf-toggle">
 
225
  onchange="toggleNoteInPdf('{{ image.id }}', this.checked)">
226
  <label class="form-check-label small" for="include_note_{{ image.id }}">In PDF</label>
227
  </div>
228
+ <button type="button" class="btn btn-sm btn-outline-info btn-pill" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}')" title="Edit Note">
229
  <i class="bi bi-pencil"></i>
230
  </button>
231
  <button type="button" class="btn btn-sm btn-outline-danger btn-pill" onclick="deleteNote('{{ image.id }}')" title="Delete Note">
 
234
  </div>
235
  </div>
236
  {% else %}
237
+ <button type="button" class="btn btn-sm btn-outline-info btn-pill w-100" onclick="openNotesModal('{{ image.id }}', '/image/processed/{{ session_id }}/{{ image.processed_filename }}')">
238
  <i class="bi bi-pencil-square me-1"></i>Add Revision Notes
239
  </button>
240
  {% endif %}
 
381
  </div>
382
  </div>
383
 
384
+ <!-- Revision Notes Modal (from partial) -->
385
+ {% include '_revision_notes.html' %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
 
387
  <div class="accordion mt-4" id="misc-accordion">
388
  <div class="accordion-item bg-dark">
 
588
  const sessionId = '{{ session_id }}';
589
  let answerKeyData = new Map();
590
  let miscellaneousQuestions = [];
 
 
 
 
 
 
 
 
 
 
591
 
592
  async function initializeTomSelect() {
593
  try {
 
615
  console.error('Error initializing Tom Select:', err);
616
  }
617
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
  // --- AUTO-SAVE FUNCTIONALITY ---
620
  let autoSaveTimeout = null;
 
728
  autoSaveQuestion(fieldset);
729
  }
730
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  function saveSettings() {
732
  // Session-specific settings
733
  const sessionSettings = {
 
837
  }
838
 
839
  function setupEventListeners() {
840
+ const jsonUpload = document.getElementById('json-upload');
841
+ if (jsonUpload) jsonUpload.addEventListener('change', handleJsonUpload);
842
+
843
+ const imagesPerPage = document.getElementById('images_per_page');
844
+ if (imagesPerPage) imagesPerPage.addEventListener('change', updateGridDefaults);
845
+
846
+ const orientation = document.getElementById('orientation');
847
+ if (orientation) orientation.addEventListener('change', updateGridDefaults);
848
+
849
+ const practiceMode = document.getElementById('practice_mode');
850
+ if (practiceMode) practiceMode.addEventListener('change', handlePracticeModeChange);
851
+
852
  document.querySelectorAll('input[id^="question_number_"]').forEach(input => {
853
  input.addEventListener('change', handleQuestionNumberChange);
854
  input.addEventListener('blur', () => autoSaveQuestion(input.closest('fieldset')));
 
859
  });
860
  document.addEventListener('keydown', handleShortcuts);
861
  document.querySelectorAll('.status-buttons').forEach(setupStatusButtons);
862
+
863
+ const questionsForm = document.getElementById('questions-form');
864
+ if (questionsForm) questionsForm.addEventListener('submit', handleFormSubmit);
865
+
866
+ const previewBtn = document.getElementById('preview-btn');
867
+ if (previewBtn) previewBtn.addEventListener('click', handlePreview);
868
 
869
  // Auto-save PDF metadata fields on blur
870
  ['pdf_subject', 'pdf_tags', 'pdf_notes', 'pdf_name'].forEach(fieldId => {
 
872
  if (field) field.addEventListener('blur', autoSaveSessionMetadata);
873
  });
874
 
875
+ const addMiscBtn = document.getElementById('add-misc-question');
876
+ if (addMiscBtn) addMiscBtn.addEventListener('click', handleAddMiscQuestion);
877
+
878
+ const extractBtn = document.getElementById('extract-classify-all');
879
+ if (extractBtn) {
880
+ extractBtn.addEventListener('click', async () => {
881
+ const spinner = extractBtn.querySelector('.spinner-border');
882
+ const text = extractBtn.querySelector('.extract-text');
883
+
884
+ spinner.classList.remove('d-none');
885
+ text.textContent = 'Processing...';
886
+ extractBtn.disabled = true;
887
+
888
+ // First, save the questions
889
+ const questions = [];
890
+ document.querySelectorAll('fieldset[data-question-index]').forEach((fieldset, i) => {
891
+ questions.push({
892
+ image_id: fieldset.querySelector('input[name^="image_id_"]').value,
893
+ question_number: fieldset.querySelector('input[name^="question_number_"]').value,
894
+ status: fieldset.querySelector('select[name^="status_"]').value,
895
+ marked_solution: fieldset.querySelector('input[name^="marked_solution_"]').value,
896
+ actual_solution: fieldset.querySelector('input[name^="actual_solution_"]').value
897
+ });
898
  });
 
899
 
900
  try {
901
  const saveResponse = await fetch('/save_questions', {
 
933
  extractBtn.disabled = false;
934
  }
935
  });
936
+ }
937
 
938
  // Add event listeners for auto-extract buttons if NVIDIA NIM is available
939
  {% if nvidia_nim_available %}
 
944
  autoExtractQuestionNumber(this, imageId, index);
945
  });
946
  });
947
+
948
  // Add event listener for auto-extract all button
949
+ const autoExtractAllBtn = document.getElementById('auto-extract-all');
950
+ if (autoExtractAllBtn) autoExtractAllBtn.addEventListener('click', autoExtractAllQuestionNumbers);
951
 
952
  // Add event listeners for delete buttons
953
  document.querySelectorAll('.delete-question-btn').forEach(button => {
templates/settings.html CHANGED
@@ -118,6 +118,28 @@
118
  </div>
119
  </fieldset>
120
  <hr>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  <button type="submit" class="btn btn-primary btn-pill btn-lg px-4">
122
  <i class="bi bi-check-lg me-1"></i>Save Settings
123
  </button>
 
118
  </div>
119
  </fieldset>
120
  <hr>
121
+ <fieldset>
122
+ <legend class="h5 mb-3"><i class="bi bi-layout-split me-2"></i>Crop View Settings</legend>
123
+ <div class="mb-3 form-check form-switch">
124
+ <input type="checkbox" class="form-check-input" id="two_page_crop" name="two_page_crop" {% if current_user.two_page_crop %}checked{% endif %}>
125
+ <label class="form-check-label" for="two_page_crop">
126
+ Enable Two-Page Layout
127
+ </label>
128
+ <div class="form-text">
129
+ Show two pages side by side in the crop view (pages 0-1, 2-3, etc.). Useful for scanned books or documents with facing pages.
130
+ </div>
131
+ </div>
132
+ <div class="mb-3 form-check form-switch">
133
+ <input type="checkbox" class="form-check-input" id="magnifier_enabled" name="magnifier_enabled" {% if current_user.magnifier_enabled %}checked{% endif %}>
134
+ <label class="form-check-label" for="magnifier_enabled">
135
+ Enable Magnifier Lens
136
+ </label>
137
+ <div class="form-text">
138
+ Show a magnifying lens when drawing crop boxes for precise selection.
139
+ </div>
140
+ </div>
141
+ </fieldset>
142
+ <hr>
143
  <button type="submit" class="btn btn-primary btn-pill btn-lg px-4">
144
  <i class="bi bi-check-lg me-1"></i>Save Settings
145
  </button>
user_auth.py CHANGED
@@ -5,7 +5,7 @@ from utils import get_db_connection
5
 
6
  class User(UserMixin):
7
  """User model for Flask-Login."""
8
- def __init__(self, id, username, email, password_hash, neetprep_enabled, dpi, color_rm_dpi, v2_default=0, magnifier_enabled=1, google_token=None, classifier_model='gemini'):
9
  self.id = id
10
  self.username = username
11
  self.email = email
@@ -15,6 +15,7 @@ class User(UserMixin):
15
  self.color_rm_dpi = color_rm_dpi
16
  self.v2_default = v2_default
17
  self.magnifier_enabled = magnifier_enabled
 
18
  self.google_token = google_token
19
  self.classifier_model = classifier_model
20
 
@@ -26,15 +27,16 @@ class User(UserMixin):
26
  if user_row:
27
  user_data = dict(user_row)
28
  return User(
29
- user_data['id'],
30
- user_data['username'],
31
- user_data['email'],
32
- user_data['password_hash'],
33
- user_data['neetprep_enabled'],
34
- user_data['dpi'],
35
  user_data.get('color_rm_dpi', 200),
36
  user_data.get('v2_default', 0),
37
  user_data.get('magnifier_enabled', 1),
 
38
  user_data.get('google_token'),
39
  user_data.get('classifier_model', 'gemini')
40
  )
@@ -48,15 +50,16 @@ class User(UserMixin):
48
  if user_row:
49
  user_data = dict(user_row)
50
  return User(
51
- user_data['id'],
52
- user_data['username'],
53
- user_data['email'],
54
- user_data['password_hash'],
55
- user_data['neetprep_enabled'],
56
- user_data['dpi'],
57
  user_data.get('color_rm_dpi', 200),
58
  user_data.get('v2_default', 0),
59
  user_data.get('magnifier_enabled', 1),
 
60
  user_data.get('google_token'),
61
  user_data.get('classifier_model', 'gemini')
62
  )
 
5
 
6
  class User(UserMixin):
7
  """User model for Flask-Login."""
8
+ def __init__(self, id, username, email, password_hash, neetprep_enabled, dpi, color_rm_dpi, v2_default=0, magnifier_enabled=1, two_page_crop=0, google_token=None, classifier_model='gemini'):
9
  self.id = id
10
  self.username = username
11
  self.email = email
 
15
  self.color_rm_dpi = color_rm_dpi
16
  self.v2_default = v2_default
17
  self.magnifier_enabled = magnifier_enabled
18
+ self.two_page_crop = two_page_crop
19
  self.google_token = google_token
20
  self.classifier_model = classifier_model
21
 
 
27
  if user_row:
28
  user_data = dict(user_row)
29
  return User(
30
+ user_data['id'],
31
+ user_data['username'],
32
+ user_data['email'],
33
+ user_data['password_hash'],
34
+ user_data['neetprep_enabled'],
35
+ user_data['dpi'],
36
  user_data.get('color_rm_dpi', 200),
37
  user_data.get('v2_default', 0),
38
  user_data.get('magnifier_enabled', 1),
39
+ user_data.get('two_page_crop', 0),
40
  user_data.get('google_token'),
41
  user_data.get('classifier_model', 'gemini')
42
  )
 
50
  if user_row:
51
  user_data = dict(user_row)
52
  return User(
53
+ user_data['id'],
54
+ user_data['username'],
55
+ user_data['email'],
56
+ user_data['password_hash'],
57
+ user_data['neetprep_enabled'],
58
+ user_data['dpi'],
59
  user_data.get('color_rm_dpi', 200),
60
  user_data.get('v2_default', 0),
61
  user_data.get('magnifier_enabled', 1),
62
+ user_data.get('two_page_crop', 0),
63
  user_data.get('google_token'),
64
  user_data.get('classifier_model', 'gemini')
65
  )