figrecipe 0.7.4__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. figrecipe/__init__.py +74 -76
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/_panel.py +67 -0
  4. figrecipe/_api/_save.py +100 -4
  5. figrecipe/_cli/__init__.py +7 -0
  6. figrecipe/_cli/_compose.py +87 -0
  7. figrecipe/_cli/_convert.py +117 -0
  8. figrecipe/_cli/_crop.py +82 -0
  9. figrecipe/_cli/_edit.py +70 -0
  10. figrecipe/_cli/_extract.py +128 -0
  11. figrecipe/_cli/_fonts.py +47 -0
  12. figrecipe/_cli/_info.py +67 -0
  13. figrecipe/_cli/_main.py +58 -0
  14. figrecipe/_cli/_reproduce.py +79 -0
  15. figrecipe/_cli/_style.py +77 -0
  16. figrecipe/_cli/_validate.py +66 -0
  17. figrecipe/_cli/_version.py +50 -0
  18. figrecipe/_composition/__init__.py +32 -0
  19. figrecipe/_composition/_alignment.py +452 -0
  20. figrecipe/_composition/_compose.py +179 -0
  21. figrecipe/_composition/_import_axes.py +127 -0
  22. figrecipe/_composition/_visibility.py +125 -0
  23. figrecipe/_dev/__init__.py +2 -0
  24. figrecipe/_dev/browser/__init__.py +69 -0
  25. figrecipe/_dev/browser/_audio.py +240 -0
  26. figrecipe/_dev/browser/_caption.py +356 -0
  27. figrecipe/_dev/browser/_click_effect.py +146 -0
  28. figrecipe/_dev/browser/_cursor.py +196 -0
  29. figrecipe/_dev/browser/_highlight.py +105 -0
  30. figrecipe/_dev/browser/_narration.py +237 -0
  31. figrecipe/_dev/browser/_recorder.py +446 -0
  32. figrecipe/_dev/browser/_utils.py +178 -0
  33. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  34. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  35. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  36. figrecipe/_editor/__init__.py +36 -36
  37. figrecipe/_editor/_bbox/_extract.py +155 -9
  38. figrecipe/_editor/_bbox/_extract_text.py +124 -0
  39. figrecipe/_editor/_call_overrides.py +183 -0
  40. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  41. figrecipe/_editor/_figure_layout.py +211 -0
  42. figrecipe/_editor/_flask_app.py +157 -16
  43. figrecipe/_editor/_helpers.py +17 -8
  44. figrecipe/_editor/_hitmap/_detect.py +89 -32
  45. figrecipe/_editor/_hitmap_main.py +4 -4
  46. figrecipe/_editor/_overrides.py +4 -1
  47. figrecipe/_editor/_plot_types_registry.py +190 -0
  48. figrecipe/_editor/_render_overrides.py +38 -11
  49. figrecipe/_editor/_renderer.py +46 -1
  50. figrecipe/_editor/_routes_annotation.py +114 -0
  51. figrecipe/_editor/_routes_axis.py +35 -6
  52. figrecipe/_editor/_routes_captions.py +130 -0
  53. figrecipe/_editor/_routes_composition.py +270 -0
  54. figrecipe/_editor/_routes_core.py +15 -173
  55. figrecipe/_editor/_routes_datatable.py +364 -0
  56. figrecipe/_editor/_routes_element.py +37 -19
  57. figrecipe/_editor/_routes_files.py +443 -0
  58. figrecipe/_editor/_routes_image.py +200 -0
  59. figrecipe/_editor/_routes_snapshot.py +94 -0
  60. figrecipe/_editor/_routes_style.py +28 -8
  61. figrecipe/_editor/_templates/__init__.py +40 -2
  62. figrecipe/_editor/_templates/_html.py +97 -103
  63. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  64. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  65. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  66. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  67. figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
  68. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  69. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  70. figrecipe/_editor/_templates/_scripts/_api.py +1 -1
  71. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  72. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  73. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  74. figrecipe/_editor/_templates/_scripts/_core.py +94 -37
  75. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  76. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  77. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  78. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  79. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  80. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  81. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  82. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  83. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  84. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  85. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  86. figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
  87. figrecipe/_editor/_templates/_scripts/_files.py +274 -40
  88. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  89. figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
  90. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  91. figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
  92. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  93. figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
  94. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  95. figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
  96. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  97. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  98. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  99. figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
  100. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  101. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  102. figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
  103. figrecipe/_editor/_templates/_styles/__init__.py +9 -0
  104. figrecipe/_editor/_templates/_styles/_base.py +47 -0
  105. figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
  106. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  107. figrecipe/_editor/_templates/_styles/_controls.py +168 -3
  108. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  109. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  110. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  111. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  112. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  113. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  114. figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
  115. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  116. figrecipe/_editor/_templates/_styles/_forms.py +98 -0
  117. figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
  118. figrecipe/_editor/_templates/_styles/_modals.py +29 -0
  119. figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
  120. figrecipe/_editor/_templates/_styles/_preview.py +213 -8
  121. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  122. figrecipe/_editor/static/audio/click.mp3 +0 -0
  123. figrecipe/_editor/static/click.mp3 +0 -0
  124. figrecipe/_editor/static/icons/favicon.ico +0 -0
  125. figrecipe/_integrations/__init__.py +17 -0
  126. figrecipe/_integrations/_scitex_stats.py +298 -0
  127. figrecipe/_params/_DECORATION_METHODS.py +2 -0
  128. figrecipe/_recorder.py +28 -3
  129. figrecipe/_reproducer/_core.py +60 -49
  130. figrecipe/_utils/__init__.py +3 -0
  131. figrecipe/_utils/_bundle.py +205 -0
  132. figrecipe/_wrappers/_axes.py +150 -2
  133. figrecipe/_wrappers/_caption_generator.py +218 -0
  134. figrecipe/_wrappers/_figure.py +26 -1
  135. figrecipe/_wrappers/_stat_annotation.py +274 -0
  136. figrecipe/styles/_style_applier.py +10 -2
  137. figrecipe/styles/presets/SCITEX.yaml +11 -4
  138. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
  139. figrecipe-0.9.0.dist-info/RECORD +277 -0
  140. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  141. figrecipe-0.7.4.dist-info/RECORD +0 -188
  142. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  143. {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Tri-directional synchronization between Data/Canvas/Properties panes.
4
+
5
+ This module coordinates selection state across:
6
+ - Data pane (datatable tabs)
7
+ - Canvas pane (figure preview with hit regions)
8
+ - Properties pane (Figure/Axis/Element tabs)
9
+
10
+ Sync directions:
11
+ - Canvas → Data: Selecting element highlights datatable tab
12
+ - Canvas → Properties: Selecting element switches to appropriate tab
13
+ - Data → Canvas: Clicking datatable tab selects element on canvas
14
+ - Data → Properties: Clicking datatable tab switches to Element tab
15
+ - Properties → Canvas: Clicking tab filters/selects canvas elements
16
+ - Properties → Data: Clicking tab highlights relevant data
17
+ """
18
+
19
+ SCRIPTS_SYNC = """
20
+ // ============================================================================
21
+ // Tri-directional Pane Synchronization
22
+ // ============================================================================
23
+
24
+ // Sync state flags to prevent infinite loops
25
+ let _syncingFromCanvas = false;
26
+ let _syncingFromData = false;
27
+ let _syncingFromProperties = false;
28
+
29
+ // ============================================================================
30
+ // Initialize Sync Hooks
31
+ // ============================================================================
32
+ function initPaneSync() {
33
+ console.log('[PaneSync] Initializing tri-directional synchronization');
34
+
35
+ // Hook datatable tab selection
36
+ hookDatatableTabSync();
37
+
38
+ // Hook properties tab clicks
39
+ hookPropertiesTabSync();
40
+
41
+ // Canvas selection is already hooked via hookCanvasSelection() in datatable core
42
+ }
43
+
44
+ // ============================================================================
45
+ // Data Pane -> Canvas/Properties Sync
46
+ // ============================================================================
47
+ function hookDatatableTabSync() {
48
+ // Wrap selectTab to add canvas/properties sync
49
+ if (typeof window.selectTab === 'function') {
50
+ const originalSelectTab = window.selectTab;
51
+ window.selectTab = function(tabId) {
52
+ originalSelectTab(tabId);
53
+
54
+ // Avoid infinite loops
55
+ if (_syncingFromCanvas || _syncingFromProperties) return;
56
+ _syncingFromData = true;
57
+
58
+ try {
59
+ syncCanvasFromDatatableTab(tabId);
60
+ syncPropertiesFromDatatableTab(tabId);
61
+ } finally {
62
+ _syncingFromData = false;
63
+ }
64
+ };
65
+ console.log('[PaneSync] Datatable tab sync hooked');
66
+ }
67
+ }
68
+
69
+ function syncCanvasFromDatatableTab(tabId) {
70
+ if (!tabId || typeof datatableTabs === 'undefined') return;
71
+ const tabState = datatableTabs[tabId];
72
+ if (!tabState) return;
73
+
74
+ const callId = tabState.callId || tabState.name;
75
+ if (!callId) return;
76
+
77
+ console.log('[PaneSync] Data->Canvas: Looking for element matching callId:', callId);
78
+
79
+ // Search currentBboxes for matching element
80
+ if (typeof currentBboxes !== 'undefined' && currentBboxes) {
81
+ // First pass: exact match on call_id or label
82
+ for (const [key, bbox] of Object.entries(currentBboxes)) {
83
+ if (key === '_meta' || !bbox) continue;
84
+ if (bbox.call_id === callId || bbox.label === callId) {
85
+ if (typeof selectElement === 'function') {
86
+ selectElement({ key, ...bbox });
87
+ console.log('[PaneSync] Data->Canvas: Selected (exact)', key);
88
+ }
89
+ return;
90
+ }
91
+ }
92
+
93
+ // Second pass: key contains callId (e.g., "scatter" in "ax1_scatter0")
94
+ for (const [key, bbox] of Object.entries(currentBboxes)) {
95
+ if (key === '_meta' || !bbox) continue;
96
+ // Match pattern: ax{N}_{callId}{N} like ax1_scatter0
97
+ const pattern = new RegExp(`ax\\d+_${callId}\\d*$`, 'i');
98
+ if (pattern.test(key)) {
99
+ if (typeof selectElement === 'function') {
100
+ selectElement({ key, ...bbox });
101
+ console.log('[PaneSync] Data->Canvas: Selected (pattern)', key);
102
+ }
103
+ return;
104
+ }
105
+ }
106
+
107
+ // Third pass: looser match - key contains callId anywhere
108
+ for (const [key, bbox] of Object.entries(currentBboxes)) {
109
+ if (key === '_meta' || !bbox) continue;
110
+ if (key.toLowerCase().includes(callId.toLowerCase())) {
111
+ if (typeof selectElement === 'function') {
112
+ selectElement({ key, ...bbox });
113
+ console.log('[PaneSync] Data->Canvas: Selected (contains)', key);
114
+ }
115
+ return;
116
+ }
117
+ }
118
+
119
+ console.log('[PaneSync] Data->Canvas: No matching element found for', callId);
120
+ }
121
+
122
+ // Fallback: select the panel associated with this tab
123
+ if (tabState.targetAxis !== null && tabState.targetAxis !== undefined) {
124
+ const axKey = `ax${tabState.targetAxis}_axes`;
125
+ if (typeof currentBboxes !== 'undefined' && currentBboxes[axKey]) {
126
+ const bbox = currentBboxes[axKey];
127
+ if (typeof selectElement === 'function') {
128
+ selectElement({ key: axKey, ...bbox, type: 'axes', ax_index: tabState.targetAxis });
129
+ console.log('[PaneSync] Data->Canvas: Selected panel', tabState.targetAxis);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ function syncPropertiesFromDatatableTab(tabId) {
136
+ if (!tabId || typeof datatableTabs === 'undefined') return;
137
+ const tabState = datatableTabs[tabId];
138
+ if (!tabState) return;
139
+
140
+ // Data tabs represent plot elements, so switch to Element tab
141
+ if (typeof switchTab === 'function') {
142
+ switchTab('element');
143
+ console.log('[PaneSync] Data->Properties: Switched to Element tab');
144
+ }
145
+ }
146
+
147
+ // ============================================================================
148
+ // Properties Pane -> Canvas/Data Sync
149
+ // ============================================================================
150
+ function hookPropertiesTabSync() {
151
+ // Add click listeners to Figure/Axis/Element tab buttons
152
+ document.addEventListener('DOMContentLoaded', () => {
153
+ const tabBtns = document.querySelectorAll('.tab-btn');
154
+ tabBtns.forEach(btn => {
155
+ btn.addEventListener('click', (e) => {
156
+ // Avoid infinite loops
157
+ if (_syncingFromCanvas || _syncingFromData) return;
158
+ _syncingFromProperties = true;
159
+
160
+ try {
161
+ const tabName = btn.id.replace('tab-', '');
162
+ syncCanvasFromPropertiesTab(tabName);
163
+ syncDataFromPropertiesTab(tabName);
164
+ } finally {
165
+ _syncingFromProperties = false;
166
+ }
167
+ });
168
+ });
169
+ console.log('[PaneSync] Properties tab sync hooked');
170
+ });
171
+ }
172
+
173
+ function syncCanvasFromPropertiesTab(tabName) {
174
+ // When switching to a properties tab, optionally clear or filter canvas selection
175
+ // For now, we'll just log - actual behavior depends on UX requirements
176
+ console.log('[PaneSync] Properties->Canvas: Tab', tabName, 'clicked');
177
+
178
+ // If Figure tab, clear element selection (show figure-level props)
179
+ if (tabName === 'figure' && typeof clearSelection === 'function') {
180
+ // Don't auto-clear as it might be disruptive
181
+ // clearSelection();
182
+ }
183
+ }
184
+
185
+ function syncDataFromPropertiesTab(tabName) {
186
+ // When switching to Element tab, try to highlight the currently selected element's data
187
+ if (tabName === 'element' && typeof selectedElement !== 'undefined' && selectedElement) {
188
+ if (typeof syncDatatableToElement === 'function') {
189
+ syncDatatableToElement(selectedElement);
190
+ }
191
+ }
192
+ console.log('[PaneSync] Properties->Data: Tab', tabName, 'clicked');
193
+ }
194
+
195
+ // ============================================================================
196
+ // Enhanced Canvas -> Data/Properties Sync (augments existing hooks)
197
+ // ============================================================================
198
+ function enhanceCanvasSync() {
199
+ // Wrap selectElement to add enhanced sync
200
+ if (typeof window.selectElement === 'function') {
201
+ const originalSelectElement = window.selectElement;
202
+ window.selectElement = function(element) {
203
+ // Avoid infinite loops
204
+ if (_syncingFromData || _syncingFromProperties) {
205
+ originalSelectElement(element);
206
+ return;
207
+ }
208
+ _syncingFromCanvas = true;
209
+
210
+ try {
211
+ originalSelectElement(element);
212
+
213
+ // Auto-switch Properties tab based on element type
214
+ if (element && typeof autoSwitchTab === 'function') {
215
+ autoSwitchTab(element.type);
216
+ }
217
+
218
+ // Sync datatable to element (already done in hookCanvasSelection, but ensure it happens)
219
+ if (element && typeof syncDatatableToElement === 'function') {
220
+ syncDatatableToElement(element);
221
+ }
222
+ } finally {
223
+ _syncingFromCanvas = false;
224
+ }
225
+ };
226
+ console.log('[PaneSync] Canvas selection sync enhanced');
227
+ }
228
+ }
229
+
230
+ // Initialize sync on page load
231
+ document.addEventListener('DOMContentLoaded', () => {
232
+ // Delay to ensure other modules are loaded
233
+ setTimeout(() => {
234
+ initPaneSync();
235
+ enhanceCanvasSync();
236
+ }, 100);
237
+ });
238
+ """
239
+
240
+ __all__ = ["SCRIPTS_SYNC"]
241
+
242
+ # EOF
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Undo/Redo functionality for the figure editor.
4
+
5
+ This module provides a history stack for tracking changes and
6
+ enabling undo/redo operations with Ctrl+Z and Ctrl+Shift+Z.
7
+ """
8
+
9
+ SCRIPTS_UNDO_REDO = """
10
+ // ==================== UNDO/REDO HISTORY ====================
11
+
12
+ // History state
13
+ const historyStack = [];
14
+ const redoStack = [];
15
+ const MAX_HISTORY = 50; // Maximum number of undo steps
16
+ let isUndoRedoInProgress = false; // Prevent recursive history recording
17
+
18
+ // Capture current state as a snapshot
19
+ function captureState() {
20
+ const state = {
21
+ overrides: collectOverrides(),
22
+ panelPositions: typeof panelPositions !== 'undefined' ? JSON.parse(JSON.stringify(panelPositions)) : {},
23
+ annotationPositions: typeof annotationPositions !== 'undefined' ? JSON.parse(JSON.stringify(annotationPositions)) : {},
24
+ timestamp: Date.now()
25
+ };
26
+ return state;
27
+ }
28
+
29
+ // Compare two states for equality
30
+ function statesEqual(a, b) {
31
+ return JSON.stringify(a.overrides) === JSON.stringify(b.overrides) &&
32
+ JSON.stringify(a.panelPositions) === JSON.stringify(b.panelPositions) &&
33
+ JSON.stringify(a.annotationPositions) === JSON.stringify(b.annotationPositions);
34
+ }
35
+
36
+ // Push current state to history (call before making changes)
37
+ function pushToHistory() {
38
+ if (isUndoRedoInProgress) return;
39
+
40
+ const state = captureState();
41
+
42
+ // Don't push if identical to last state
43
+ if (historyStack.length > 0) {
44
+ const lastState = historyStack[historyStack.length - 1];
45
+ if (statesEqual(lastState, state)) {
46
+ return;
47
+ }
48
+ }
49
+
50
+ historyStack.push(state);
51
+
52
+ // Clear redo stack when new action is performed
53
+ redoStack.length = 0;
54
+
55
+ // Trim history if too long
56
+ while (historyStack.length > MAX_HISTORY) {
57
+ historyStack.shift();
58
+ }
59
+
60
+ updateUndoRedoButtons();
61
+ console.log('[History] Pushed state, stack size:', historyStack.length);
62
+ }
63
+
64
+ // Apply a state snapshot to the form
65
+ async function applyState(state) {
66
+ isUndoRedoInProgress = true;
67
+
68
+ try {
69
+ const overrides = state.overrides;
70
+
71
+ for (const [key, value] of Object.entries(overrides)) {
72
+ const element = document.getElementById(key);
73
+ if (!element) continue;
74
+
75
+ if (element.type === 'checkbox') {
76
+ element.checked = Boolean(value);
77
+ } else if (element.type === 'range') {
78
+ element.value = value;
79
+ const valueSpan = document.getElementById(key + '_value');
80
+ if (valueSpan) valueSpan.textContent = value;
81
+ } else if (element.type === 'color') {
82
+ element.value = value;
83
+ } else if (element.tagName === 'SELECT') {
84
+ element.value = value;
85
+ } else {
86
+ element.value = value;
87
+ }
88
+ }
89
+
90
+ // Restore panel positions if they differ
91
+ if (state.panelPositions && typeof panelPositions !== 'undefined') {
92
+ const axKeys = Object.keys(state.panelPositions).sort();
93
+ for (let i = 0; i < axKeys.length; i++) {
94
+ const axKey = axKeys[i];
95
+ const savedPos = state.panelPositions[axKey];
96
+ const currentPos = panelPositions[axKey];
97
+
98
+ // Check if position changed
99
+ if (currentPos && savedPos &&
100
+ (Math.abs(savedPos.left - currentPos.left) > 0.1 ||
101
+ Math.abs(savedPos.top - currentPos.top) > 0.1)) {
102
+ // Restore panel position via API
103
+ try {
104
+ await fetch('/update_axes_position', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({
108
+ ax_index: i,
109
+ left: savedPos.left,
110
+ top: savedPos.top,
111
+ width: savedPos.width,
112
+ height: savedPos.height
113
+ })
114
+ });
115
+ // Update local panelPositions to match restored state
116
+ panelPositions[axKey] = { ...savedPos };
117
+ } catch (e) {
118
+ console.error('[History] Failed to restore panel position:', e);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Restore annotation positions if they differ
125
+ if (state.annotationPositions && typeof annotationPositions !== 'undefined') {
126
+ let needsRefresh = false;
127
+ for (const [key, savedPos] of Object.entries(state.annotationPositions)) {
128
+ const currentPos = annotationPositions[key];
129
+
130
+ // Check if position changed
131
+ if (!currentPos ||
132
+ Math.abs(savedPos.x - (currentPos?.x || 0)) > 0.001 ||
133
+ Math.abs(savedPos.y - (currentPos?.y || 0)) > 0.001) {
134
+
135
+ // Parse key formats:
136
+ // "ax0_panel_label" -> axIndex=0, type=panel_label, textIndex=0
137
+ // "ax0_text_0" -> axIndex=0, type=text, textIndex=0
138
+ let axIndex, annotationType, textIndex;
139
+
140
+ if (key.includes('_panel_label')) {
141
+ const match = key.match(/ax(\\d+)_panel_label/);
142
+ if (match) {
143
+ axIndex = parseInt(match[1], 10);
144
+ annotationType = 'panel_label';
145
+ textIndex = 0;
146
+ }
147
+ } else if (key.includes('_text_')) {
148
+ const match = key.match(/ax(\\d+)_text_(\\d+)/);
149
+ if (match) {
150
+ axIndex = parseInt(match[1], 10);
151
+ annotationType = 'text';
152
+ textIndex = parseInt(match[2], 10);
153
+ }
154
+ }
155
+
156
+ if (axIndex !== undefined) {
157
+ try {
158
+ const response = await fetch('/update_annotation_position', {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({
162
+ ax_index: axIndex,
163
+ annotation_type: annotationType,
164
+ text_index: textIndex,
165
+ x: savedPos.x,
166
+ y: savedPos.y
167
+ })
168
+ });
169
+ const data = await response.json();
170
+
171
+ if (data.success && data.image) {
172
+ // Update preview image
173
+ const img = document.getElementById('preview-image');
174
+ if (img) {
175
+ img.src = 'data:image/png;base64,' + data.image;
176
+ }
177
+ // Update bboxes
178
+ if (data.bboxes && typeof currentBboxes !== 'undefined') {
179
+ currentBboxes = data.bboxes;
180
+ }
181
+ needsRefresh = true;
182
+ }
183
+
184
+ // Update local annotationPositions to match restored state
185
+ annotationPositions[key] = { ...savedPos };
186
+ console.log('[History] Restored annotation position:', key);
187
+ } catch (e) {
188
+ console.error('[History] Failed to restore annotation position:', e);
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ // Refresh hitmap if positions were restored
195
+ if (needsRefresh && typeof loadHitmap === 'function') {
196
+ loadHitmap();
197
+ if (typeof updateHitRegions === 'function') {
198
+ updateHitRegions();
199
+ }
200
+ if (typeof initAnnotationPositions === 'function') {
201
+ initAnnotationPositions();
202
+ }
203
+ }
204
+ }
205
+
206
+ // Update preview with the restored state
207
+ updatePreview();
208
+
209
+ } finally {
210
+ isUndoRedoInProgress = false;
211
+ }
212
+ }
213
+
214
+ // Undo last action
215
+ async function undo() {
216
+ if (historyStack.length === 0) {
217
+ showToast('Nothing to undo', 'info');
218
+ return;
219
+ }
220
+
221
+ // Save current state to redo stack
222
+ const currentState = captureState();
223
+ redoStack.push(currentState);
224
+
225
+ // Pop and apply previous state
226
+ const previousState = historyStack.pop();
227
+ await applyState(previousState);
228
+
229
+ updateUndoRedoButtons();
230
+ showToast('Undo', 'info');
231
+ console.log('[History] Undo, remaining:', historyStack.length);
232
+ }
233
+
234
+ // Redo last undone action
235
+ async function redo() {
236
+ if (redoStack.length === 0) {
237
+ showToast('Nothing to redo', 'info');
238
+ return;
239
+ }
240
+
241
+ // Save current state to history
242
+ const currentState = captureState();
243
+ historyStack.push(currentState);
244
+
245
+ // Pop and apply redo state
246
+ const redoState = redoStack.pop();
247
+ await applyState(redoState);
248
+
249
+ updateUndoRedoButtons();
250
+ showToast('Redo', 'info');
251
+ console.log('[History] Redo, remaining redo:', redoStack.length);
252
+ }
253
+
254
+ // Update undo/redo button states
255
+ function updateUndoRedoButtons() {
256
+ const undoBtn = document.getElementById('btn-undo');
257
+ const redoBtn = document.getElementById('btn-redo');
258
+
259
+ if (undoBtn) {
260
+ undoBtn.disabled = historyStack.length === 0;
261
+ undoBtn.title = historyStack.length > 0
262
+ ? `Undo (${historyStack.length} steps available)`
263
+ : 'Nothing to undo';
264
+ }
265
+
266
+ if (redoBtn) {
267
+ redoBtn.disabled = redoStack.length === 0;
268
+ redoBtn.title = redoStack.length > 0
269
+ ? `Redo (${redoStack.length} steps available)`
270
+ : 'Nothing to redo';
271
+ }
272
+ }
273
+
274
+ // Clear all history (e.g., when switching files)
275
+ function clearHistory() {
276
+ historyStack.length = 0;
277
+ redoStack.length = 0;
278
+ updateUndoRedoButtons();
279
+ console.log('[History] Cleared');
280
+ }
281
+
282
+ // Hook into form changes to record history
283
+ function initUndoRedo() {
284
+ // Capture initial state
285
+ pushToHistory();
286
+
287
+ // Add change listeners to all form inputs
288
+ const inputs = document.querySelectorAll('input, select');
289
+ inputs.forEach(input => {
290
+ if (input.id === 'dark-mode-toggle') return;
291
+ if (!input.id) return;
292
+
293
+ // Capture state before change
294
+ input.addEventListener('focus', () => {
295
+ pushToHistory();
296
+ });
297
+
298
+ // For inputs without focus events (like range sliders)
299
+ if (input.type === 'range') {
300
+ let rangeStartValue = null;
301
+ input.addEventListener('mousedown', () => {
302
+ rangeStartValue = input.value;
303
+ pushToHistory();
304
+ });
305
+ }
306
+
307
+ // For select elements
308
+ if (input.tagName === 'SELECT') {
309
+ input.addEventListener('mousedown', () => {
310
+ pushToHistory();
311
+ });
312
+ }
313
+ });
314
+
315
+ // Initialize button states
316
+ updateUndoRedoButtons();
317
+
318
+ console.log('[History] Undo/Redo initialized');
319
+ }
320
+
321
+ // Initialize when DOM is ready
322
+ if (document.readyState === 'loading') {
323
+ document.addEventListener('DOMContentLoaded', initUndoRedo);
324
+ } else {
325
+ // Small delay to ensure other scripts have initialized
326
+ setTimeout(initUndoRedo, 100);
327
+ }
328
+
329
+ // Add button click handlers after DOM is ready
330
+ document.addEventListener('DOMContentLoaded', function() {
331
+ const undoBtn = document.getElementById('btn-undo');
332
+ const redoBtn = document.getElementById('btn-redo');
333
+
334
+ if (undoBtn) {
335
+ undoBtn.addEventListener('click', undo);
336
+ }
337
+
338
+ if (redoBtn) {
339
+ redoBtn.addEventListener('click', redo);
340
+ }
341
+ });
342
+
343
+ console.log('[UndoRedo] Module loaded - Ctrl+Z to undo, Ctrl+Shift+Z to redo');
344
+ """
345
+
346
+ __all__ = ["SCRIPTS_UNDO_REDO"]
347
+
348
+ # EOF
@@ -11,10 +11,13 @@ function initializeZoomPan() {
11
11
 
12
12
  if (!wrapper || !container) return;
13
13
 
14
- // Zoom buttons
15
- document.getElementById('btn-zoom-in')?.addEventListener('click', () => setZoom(zoomLevel + ZOOM_STEP));
16
- document.getElementById('btn-zoom-out')?.addEventListener('click', () => setZoom(zoomLevel - ZOOM_STEP));
17
- document.getElementById('btn-zoom-reset')?.addEventListener('click', () => setZoom(1.0));
14
+ // Zoom dropdown
15
+ const zoomSelect = document.getElementById('zoom-select');
16
+ zoomSelect?.addEventListener('change', (e) => {
17
+ setZoom(parseInt(e.target.value) / 100);
18
+ });
19
+
20
+ // Fit button
18
21
  document.getElementById('btn-zoom-fit')?.addEventListener('click', zoomToFit);
19
22
 
20
23
  // Mouse wheel zoom
@@ -122,10 +125,16 @@ function setZoom(newLevel) {
122
125
  }
123
126
  }
124
127
 
125
- // Update zoom level display
126
- const levelDisplay = document.getElementById('zoom-level');
127
- if (levelDisplay) {
128
- levelDisplay.textContent = Math.round(zoomLevel * 100) + '%';
128
+ // Update zoom dropdown to nearest value
129
+ const zoomSelect = document.getElementById('zoom-select');
130
+ if (zoomSelect) {
131
+ const percent = Math.round(zoomLevel * 100);
132
+ // Find closest option
133
+ const options = Array.from(zoomSelect.options).map(o => parseInt(o.value));
134
+ const closest = options.reduce((prev, curr) =>
135
+ Math.abs(curr - percent) < Math.abs(prev - percent) ? curr : prev
136
+ );
137
+ zoomSelect.value = closest;
129
138
  }
130
139
  }
131
140
 
@@ -144,32 +153,56 @@ function zoomToFit() {
144
153
  setZoom(Math.min(scaleX, scaleY, 1.0));
145
154
  }
146
155
 
156
+ // Find nearest scrollable parent element
157
+ function findScrollableParent(element) {
158
+ while (element && element !== document.body) {
159
+ const style = window.getComputedStyle(element);
160
+ const overflowY = style.overflowY;
161
+ const overflowX = style.overflowX;
162
+ const isScrollable = (overflowY === 'auto' || overflowY === 'scroll' ||
163
+ overflowX === 'auto' || overflowX === 'scroll');
164
+ const canScroll = element.scrollHeight > element.clientHeight ||
165
+ element.scrollWidth > element.clientWidth;
166
+ if (isScrollable && canScroll) {
167
+ return element;
168
+ }
169
+ element = element.parentElement;
170
+ }
171
+ return null;
172
+ }
173
+
147
174
  function startPan(e) {
148
- const wrapper = document.getElementById('preview-wrapper');
175
+ // Find scrollable container under mouse
176
+ panTarget = findScrollableParent(e.target);
177
+ if (!panTarget) {
178
+ // Fallback to preview-wrapper for canvas
179
+ panTarget = document.getElementById('preview-wrapper');
180
+ }
181
+ if (!panTarget) return;
182
+
149
183
  isPanning = true;
150
184
  panStartX = e.clientX;
151
185
  panStartY = e.clientY;
152
- scrollStartX = wrapper.scrollLeft;
153
- scrollStartY = wrapper.scrollTop;
154
- wrapper.classList.add('panning');
186
+ scrollStartX = panTarget.scrollLeft;
187
+ scrollStartY = panTarget.scrollTop;
188
+ panTarget.classList.add('panning');
155
189
  }
156
190
 
157
191
  function doPan(e) {
158
- if (!isPanning) return;
192
+ if (!isPanning || !panTarget) return;
159
193
 
160
- const wrapper = document.getElementById('preview-wrapper');
161
194
  const dx = e.clientX - panStartX;
162
195
  const dy = e.clientY - panStartY;
163
196
 
164
- wrapper.scrollLeft = scrollStartX - dx;
165
- wrapper.scrollTop = scrollStartY - dy;
197
+ panTarget.scrollLeft = scrollStartX - dx;
198
+ panTarget.scrollTop = scrollStartY - dy;
166
199
  }
167
200
 
168
201
  function endPan() {
169
- if (isPanning) {
170
- const wrapper = document.getElementById('preview-wrapper');
171
- wrapper.classList.remove('panning');
202
+ if (isPanning && panTarget) {
203
+ panTarget.classList.remove('panning');
172
204
  isPanning = false;
205
+ panTarget = null;
173
206
  }
174
207
  }
175
208