Spaces:
Running
Running
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 +10 -0
- image_routes.py +64 -1
- routes/processing.py +50 -6
- routes/serving.py +3 -2
- settings_routes.py +7 -3
- templates/_revision_notes.html +577 -0
- templates/cropv2.html +672 -130
- templates/question_entry_v2.html +53 -631
- templates/settings.html +22 -0
- user_auth.py +16 -13
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 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 -
|
| 359 |
#dataPanel {
|
| 360 |
-
bottom:
|
|
|
|
| 361 |
}
|
| 362 |
|
| 363 |
-
/* Brightness Panel -
|
| 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.
|
|
|
|
| 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 =
|
| 806 |
-
const
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
els.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 953 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
updateMagnifierState(x, y, true);
|
| 955 |
|
| 956 |
-
const hit = hitTest(x, y);
|
| 957 |
if (hit) {
|
| 958 |
-
dragTarget = hit;
|
| 959 |
-
|
| 960 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
startX = x;
|
| 962 |
startY = y;
|
| 963 |
updateToolbar();
|
| 964 |
updateStitchButton();
|
| 965 |
} else {
|
| 966 |
-
|
| 967 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 979 |
updateMagnifierState(x, y, true);
|
| 980 |
-
|
| 981 |
const dx = x - startX, dy = y - startY;
|
| 982 |
if (dragTarget) {
|
| 983 |
-
const
|
|
|
|
| 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 |
-
|
| 998 |
-
const
|
| 999 |
-
|
| 1000 |
-
els.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
}
|
| 1002 |
}
|
| 1003 |
}
|
|
@@ -1006,13 +1310,15 @@
|
|
| 1006 |
updateMagnifierState(0, 0, false);
|
| 1007 |
|
| 1008 |
if (isDrawing) {
|
| 1009 |
-
const
|
|
|
|
|
|
|
| 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) *
|
| 1015 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
};
|
| 1065 |
-
|
| 1066 |
document.getElementById('move-up-btn').onclick = (e) => {
|
| 1067 |
e.stopPropagation();
|
| 1068 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
| 1121 |
boxes[selectedBoxIndex][dataFields[id]] = e.target.value;
|
| 1122 |
saveBoxes();
|
| 1123 |
}
|
|
@@ -1203,16 +1598,23 @@
|
|
| 1203 |
}
|
| 1204 |
|
| 1205 |
function updateDataPanel() {
|
| 1206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 1228 |
els.toolbar.classList.remove('show');
|
| 1229 |
setTimeout(() => { if (!els.toolbar.classList.contains('show')) els.toolbar.style.display = 'none'; }, 200);
|
| 1230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 1284 |
box: cleanBox
|
| 1285 |
};
|
| 1286 |
localStorage.setItem('gemini_stitch_buffer', JSON.stringify(stitchBuffer));
|
| 1287 |
toast('Copied!');
|
| 1288 |
}
|
| 1289 |
-
|
| 1290 |
updateStitchButton();
|
| 1291 |
drawBoxes();
|
|
|
|
| 1292 |
}
|
| 1293 |
|
| 1294 |
function toast(msg) {
|
|
@@ -1300,51 +1769,90 @@
|
|
| 1300 |
}
|
| 1301 |
|
| 1302 |
async function processPage() {
|
| 1303 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
})
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
| 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 |
-
|
| 1388 |
-
els.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 287 |
<div class="note-card">
|
| 288 |
-
<div class="
|
| 289 |
-
<
|
|
|
|
| 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 }}'
|
| 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 |
-
|
| 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')
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
document.getElementById('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1449 |
-
document.getElementById('
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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')
|
| 1458 |
-
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
| 1467 |
-
|
| 1468 |
-
|
| 1469 |
-
|
| 1470 |
-
|
| 1471 |
-
questions
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
|
| 1475 |
-
|
| 1476 |
-
|
|
|
|
|
|
|
|
|
|
| 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')
|
|
|
|
| 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 |
)
|