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.
- figrecipe/__init__.py +74 -76
- figrecipe/__main__.py +12 -0
- figrecipe/_api/_panel.py +67 -0
- figrecipe/_api/_save.py +100 -4
- figrecipe/_cli/__init__.py +7 -0
- figrecipe/_cli/_compose.py +87 -0
- figrecipe/_cli/_convert.py +117 -0
- figrecipe/_cli/_crop.py +82 -0
- figrecipe/_cli/_edit.py +70 -0
- figrecipe/_cli/_extract.py +128 -0
- figrecipe/_cli/_fonts.py +47 -0
- figrecipe/_cli/_info.py +67 -0
- figrecipe/_cli/_main.py +58 -0
- figrecipe/_cli/_reproduce.py +79 -0
- figrecipe/_cli/_style.py +77 -0
- figrecipe/_cli/_validate.py +66 -0
- figrecipe/_cli/_version.py +50 -0
- figrecipe/_composition/__init__.py +32 -0
- figrecipe/_composition/_alignment.py +452 -0
- figrecipe/_composition/_compose.py +179 -0
- figrecipe/_composition/_import_axes.py +127 -0
- figrecipe/_composition/_visibility.py +125 -0
- figrecipe/_dev/__init__.py +2 -0
- figrecipe/_dev/browser/__init__.py +69 -0
- figrecipe/_dev/browser/_audio.py +240 -0
- figrecipe/_dev/browser/_caption.py +356 -0
- figrecipe/_dev/browser/_click_effect.py +146 -0
- figrecipe/_dev/browser/_cursor.py +196 -0
- figrecipe/_dev/browser/_highlight.py +105 -0
- figrecipe/_dev/browser/_narration.py +237 -0
- figrecipe/_dev/browser/_recorder.py +446 -0
- figrecipe/_dev/browser/_utils.py +178 -0
- figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
- figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
- figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
- figrecipe/_editor/__init__.py +36 -36
- figrecipe/_editor/_bbox/_extract.py +155 -9
- figrecipe/_editor/_bbox/_extract_text.py +124 -0
- figrecipe/_editor/_call_overrides.py +183 -0
- figrecipe/_editor/_datatable_plot_handlers.py +249 -0
- figrecipe/_editor/_figure_layout.py +211 -0
- figrecipe/_editor/_flask_app.py +157 -16
- figrecipe/_editor/_helpers.py +17 -8
- figrecipe/_editor/_hitmap/_detect.py +89 -32
- figrecipe/_editor/_hitmap_main.py +4 -4
- figrecipe/_editor/_overrides.py +4 -1
- figrecipe/_editor/_plot_types_registry.py +190 -0
- figrecipe/_editor/_render_overrides.py +38 -11
- figrecipe/_editor/_renderer.py +46 -1
- figrecipe/_editor/_routes_annotation.py +114 -0
- figrecipe/_editor/_routes_axis.py +35 -6
- figrecipe/_editor/_routes_captions.py +130 -0
- figrecipe/_editor/_routes_composition.py +270 -0
- figrecipe/_editor/_routes_core.py +15 -173
- figrecipe/_editor/_routes_datatable.py +364 -0
- figrecipe/_editor/_routes_element.py +37 -19
- figrecipe/_editor/_routes_files.py +443 -0
- figrecipe/_editor/_routes_image.py +200 -0
- figrecipe/_editor/_routes_snapshot.py +94 -0
- figrecipe/_editor/_routes_style.py +28 -8
- figrecipe/_editor/_templates/__init__.py +40 -2
- figrecipe/_editor/_templates/_html.py +97 -103
- figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
- figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
- figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
- figrecipe/_editor/_templates/_html_datatable.py +92 -0
- figrecipe/_editor/_templates/_scripts/__init__.py +58 -0
- figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
- figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
- figrecipe/_editor/_templates/_scripts/_api.py +1 -1
- figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
- figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
- figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
- figrecipe/_editor/_templates/_scripts/_core.py +94 -37
- figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
- figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
- figrecipe/_editor/_templates/_scripts/_element_editor.py +17 -2
- figrecipe/_editor/_templates/_scripts/_files.py +274 -40
- figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
- figrecipe/_editor/_templates/_scripts/_hitmap.py +87 -84
- figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
- figrecipe/_editor/_templates/_scripts/_legend_drag.py +5 -0
- figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
- figrecipe/_editor/_templates/_scripts/_panel_drag.py +219 -48
- figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
- figrecipe/_editor/_templates/_scripts/_panel_position.py +238 -54
- figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
- figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
- figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
- figrecipe/_editor/_templates/_scripts/_selection.py +8 -1
- figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
- figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
- figrecipe/_editor/_templates/_scripts/_zoom.py +52 -19
- figrecipe/_editor/_templates/_styles/__init__.py +9 -0
- figrecipe/_editor/_templates/_styles/_base.py +47 -0
- figrecipe/_editor/_templates/_styles/_buttons.py +127 -6
- figrecipe/_editor/_templates/_styles/_composition.py +87 -0
- figrecipe/_editor/_templates/_styles/_controls.py +168 -3
- figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
- figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
- figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
- figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
- figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
- figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
- figrecipe/_editor/_templates/_styles/_dynamic_props.py +5 -5
- figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
- figrecipe/_editor/_templates/_styles/_forms.py +98 -0
- figrecipe/_editor/_templates/_styles/_hitmap.py +7 -0
- figrecipe/_editor/_templates/_styles/_modals.py +29 -0
- figrecipe/_editor/_templates/_styles/_overlays.py +5 -5
- figrecipe/_editor/_templates/_styles/_preview.py +213 -8
- figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
- figrecipe/_editor/static/audio/click.mp3 +0 -0
- figrecipe/_editor/static/click.mp3 +0 -0
- figrecipe/_editor/static/icons/favicon.ico +0 -0
- figrecipe/_integrations/__init__.py +17 -0
- figrecipe/_integrations/_scitex_stats.py +298 -0
- figrecipe/_params/_DECORATION_METHODS.py +2 -0
- figrecipe/_recorder.py +28 -3
- figrecipe/_reproducer/_core.py +60 -49
- figrecipe/_utils/__init__.py +3 -0
- figrecipe/_utils/_bundle.py +205 -0
- figrecipe/_wrappers/_axes.py +150 -2
- figrecipe/_wrappers/_caption_generator.py +218 -0
- figrecipe/_wrappers/_figure.py +26 -1
- figrecipe/_wrappers/_stat_annotation.py +274 -0
- figrecipe/styles/_style_applier.py +10 -2
- figrecipe/styles/presets/SCITEX.yaml +11 -4
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/METADATA +144 -146
- figrecipe-0.9.0.dist-info/RECORD +277 -0
- figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
- figrecipe-0.7.4.dist-info/RECORD +0 -188
- {figrecipe-0.7.4.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
- {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
|
|
15
|
-
document.getElementById('
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
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
|
-
|
|
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 =
|
|
153
|
-
scrollStartY =
|
|
154
|
-
|
|
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
|
-
|
|
165
|
-
|
|
197
|
+
panTarget.scrollLeft = scrollStartX - dx;
|
|
198
|
+
panTarget.scrollTop = scrollStartY - dy;
|
|
166
199
|
}
|
|
167
200
|
|
|
168
201
|
function endPan() {
|
|
169
|
-
if (isPanning) {
|
|
170
|
-
|
|
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
|
|