scitex 2.7.3__py3-none-any.whl → 2.8.1__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.
- scitex/__version__.py +1 -1
- scitex/dev/plt/__init__.py +0 -0
- scitex/dev/plt/plot_mpl_axhline.py +0 -0
- scitex/dev/plt/plot_mpl_axhspan.py +0 -0
- scitex/dev/plt/plot_mpl_axvline.py +0 -0
- scitex/dev/plt/plot_mpl_axvspan.py +0 -0
- scitex/dev/plt/plot_mpl_bar.py +0 -0
- scitex/dev/plt/plot_mpl_barh.py +0 -0
- scitex/dev/plt/plot_mpl_boxplot.py +0 -0
- scitex/dev/plt/plot_mpl_contour.py +0 -0
- scitex/dev/plt/plot_mpl_contourf.py +0 -0
- scitex/dev/plt/plot_mpl_errorbar.py +0 -0
- scitex/dev/plt/plot_mpl_eventplot.py +0 -0
- scitex/dev/plt/plot_mpl_fill.py +0 -0
- scitex/dev/plt/plot_mpl_fill_between.py +0 -0
- scitex/dev/plt/plot_mpl_hexbin.py +0 -0
- scitex/dev/plt/plot_mpl_hist.py +0 -0
- scitex/dev/plt/plot_mpl_hist2d.py +0 -0
- scitex/dev/plt/plot_mpl_imshow.py +0 -0
- scitex/dev/plt/plot_mpl_pcolormesh.py +0 -0
- scitex/dev/plt/plot_mpl_pie.py +0 -0
- scitex/dev/plt/plot_mpl_plot.py +0 -0
- scitex/dev/plt/plot_mpl_quiver.py +0 -0
- scitex/dev/plt/plot_mpl_scatter.py +0 -0
- scitex/dev/plt/plot_mpl_stackplot.py +0 -0
- scitex/dev/plt/plot_mpl_stem.py +0 -0
- scitex/dev/plt/plot_mpl_step.py +0 -0
- scitex/dev/plt/plot_mpl_violinplot.py +0 -0
- scitex/dev/plt/plot_sns_barplot.py +0 -0
- scitex/dev/plt/plot_sns_boxplot.py +0 -0
- scitex/dev/plt/plot_sns_heatmap.py +0 -0
- scitex/dev/plt/plot_sns_histplot.py +0 -0
- scitex/dev/plt/plot_sns_kdeplot.py +0 -0
- scitex/dev/plt/plot_sns_lineplot.py +0 -0
- scitex/dev/plt/plot_sns_scatterplot.py +0 -0
- scitex/dev/plt/plot_sns_stripplot.py +0 -0
- scitex/dev/plt/plot_sns_swarmplot.py +0 -0
- scitex/dev/plt/plot_sns_violinplot.py +0 -0
- scitex/dev/plt/plot_stx_bar.py +0 -0
- scitex/dev/plt/plot_stx_barh.py +0 -0
- scitex/dev/plt/plot_stx_box.py +0 -0
- scitex/dev/plt/plot_stx_boxplot.py +0 -0
- scitex/dev/plt/plot_stx_conf_mat.py +0 -0
- scitex/dev/plt/plot_stx_contour.py +0 -0
- scitex/dev/plt/plot_stx_ecdf.py +0 -0
- scitex/dev/plt/plot_stx_errorbar.py +0 -0
- scitex/dev/plt/plot_stx_fill_between.py +0 -0
- scitex/dev/plt/plot_stx_fillv.py +0 -0
- scitex/dev/plt/plot_stx_heatmap.py +0 -0
- scitex/dev/plt/plot_stx_image.py +0 -0
- scitex/dev/plt/plot_stx_imshow.py +0 -0
- scitex/dev/plt/plot_stx_joyplot.py +0 -0
- scitex/dev/plt/plot_stx_kde.py +0 -0
- scitex/dev/plt/plot_stx_line.py +0 -0
- scitex/dev/plt/plot_stx_mean_ci.py +0 -0
- scitex/dev/plt/plot_stx_mean_std.py +0 -0
- scitex/dev/plt/plot_stx_median_iqr.py +0 -0
- scitex/dev/plt/plot_stx_raster.py +0 -0
- scitex/dev/plt/plot_stx_rectangle.py +0 -0
- scitex/dev/plt/plot_stx_scatter.py +0 -0
- scitex/dev/plt/plot_stx_shaded_line.py +0 -0
- scitex/dev/plt/plot_stx_violin.py +0 -0
- scitex/dev/plt/plot_stx_violinplot.py +0 -0
- scitex/diagram/README.md +197 -0
- scitex/diagram/__init__.py +48 -0
- scitex/diagram/_compile.py +312 -0
- scitex/diagram/_diagram.py +355 -0
- scitex/diagram/_presets.py +173 -0
- scitex/diagram/_schema.py +182 -0
- scitex/diagram/_split.py +278 -0
- scitex/fig/editor/__init__.py +5 -2
- scitex/fig/editor/_dearpygui_editor.py +1 -1
- scitex/fig/editor/_mpl_editor.py +1 -1
- scitex/fig/editor/_qt_editor.py +1 -1
- scitex/fig/editor/_tkinter_editor.py +1 -1
- scitex/fig/editor/edit/__init__.py +50 -0
- scitex/fig/editor/edit/backend_detector.py +109 -0
- scitex/fig/editor/edit/bundle_resolver.py +240 -0
- scitex/fig/editor/edit/editor_launcher.py +239 -0
- scitex/fig/editor/edit/manual_handler.py +53 -0
- scitex/fig/editor/edit/panel_loader.py +232 -0
- scitex/fig/editor/edit/path_resolver.py +67 -0
- scitex/fig/editor/flask_editor/_bbox.py +23 -0
- scitex/fig/editor/flask_editor/_core.py +908 -103
- scitex/fig/editor/flask_editor/_renderer.py +74 -0
- scitex/fig/editor/flask_editor/static/css/base/reset.css +41 -0
- scitex/fig/editor/flask_editor/static/css/base/typography.css +16 -0
- scitex/fig/editor/flask_editor/static/css/base/variables.css +85 -0
- scitex/fig/editor/flask_editor/static/css/components/buttons.css +217 -0
- scitex/fig/editor/flask_editor/static/css/components/context-menu.css +93 -0
- scitex/fig/editor/flask_editor/static/css/components/dropdown.css +57 -0
- scitex/fig/editor/flask_editor/static/css/components/forms.css +112 -0
- scitex/fig/editor/flask_editor/static/css/components/modal.css +59 -0
- scitex/fig/editor/flask_editor/static/css/components/sections.css +212 -0
- scitex/fig/editor/flask_editor/static/css/features/canvas.css +176 -0
- scitex/fig/editor/flask_editor/static/css/features/element-inspector.css +190 -0
- scitex/fig/editor/flask_editor/static/css/features/loading.css +59 -0
- scitex/fig/editor/flask_editor/static/css/features/overlay.css +45 -0
- scitex/fig/editor/flask_editor/static/css/features/panel-grid.css +95 -0
- scitex/fig/editor/flask_editor/static/css/features/selection.css +101 -0
- scitex/fig/editor/flask_editor/static/css/features/statistics.css +138 -0
- scitex/fig/editor/flask_editor/static/css/index.css +31 -0
- scitex/fig/editor/flask_editor/static/css/layout/container.css +7 -0
- scitex/fig/editor/flask_editor/static/css/layout/controls.css +56 -0
- scitex/fig/editor/flask_editor/static/css/layout/preview.css +78 -0
- scitex/fig/editor/flask_editor/static/js/alignment/axis.js +314 -0
- scitex/fig/editor/flask_editor/static/js/alignment/basic.js +107 -0
- scitex/fig/editor/flask_editor/static/js/alignment/distribute.js +54 -0
- scitex/fig/editor/flask_editor/static/js/canvas/canvas.js +172 -0
- scitex/fig/editor/flask_editor/static/js/canvas/dragging.js +258 -0
- scitex/fig/editor/flask_editor/static/js/canvas/resize.js +48 -0
- scitex/fig/editor/flask_editor/static/js/canvas/selection.js +71 -0
- scitex/fig/editor/flask_editor/static/js/core/api.js +288 -0
- scitex/fig/editor/flask_editor/static/js/core/state.js +143 -0
- scitex/fig/editor/flask_editor/static/js/core/utils.js +245 -0
- scitex/fig/editor/flask_editor/static/js/dev/element-inspector.js +992 -0
- scitex/fig/editor/flask_editor/static/js/editor/bbox.js +339 -0
- scitex/fig/editor/flask_editor/static/js/editor/element-drag.js +286 -0
- scitex/fig/editor/flask_editor/static/js/editor/overlay.js +371 -0
- scitex/fig/editor/flask_editor/static/js/editor/preview.js +293 -0
- scitex/fig/editor/flask_editor/static/js/main.js +426 -0
- scitex/fig/editor/flask_editor/static/js/shortcuts/context-menu.js +152 -0
- scitex/fig/editor/flask_editor/static/js/shortcuts/keyboard.js +265 -0
- scitex/fig/editor/flask_editor/static/js/ui/controls.js +184 -0
- scitex/fig/editor/flask_editor/static/js/ui/download.js +57 -0
- scitex/fig/editor/flask_editor/static/js/ui/help.js +100 -0
- scitex/fig/editor/flask_editor/static/js/ui/theme.js +34 -0
- scitex/fig/editor/flask_editor/templates/__init__.py +95 -5
- scitex/fig/editor/flask_editor/templates/_html.py +27 -9
- scitex/fig/editor/flask_editor/templates/_scripts.py +1928 -131
- scitex/fig/editor/flask_editor/templates/_styles.py +363 -51
- scitex/fig/io/_bundle.py +97 -12
- scitex/io/__init__.py +12 -0
- scitex/io/_bundle.py +69 -10
- scitex/io/_zip_bundle.py +439 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/__init__.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_labels.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_metadata.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_visual.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/__init__.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_base.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_scientific.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_statistical.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_stx_aliases.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_RawMatplotlibMixin.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/__init__.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_base.py +0 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +0 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_bar.py +0 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_barh.py +0 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_errorbar.py +0 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter.py +0 -0
- scitex/plt/io/_layered_bundle.py +0 -0
- scitex/schema/_plot.py +0 -0
- {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/METADATA +1 -1
- {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/RECORD +78 -22
- scitex/fig/editor/_edit.py +0 -751
- {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/WHEEL +0 -0
- {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/entry_points.txt +0 -0
- {scitex-2.7.3.dist-info → scitex-2.8.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
# File: ./src/scitex/vis/editor/flask_editor/templates/scripts.py
|
|
4
|
-
"""JavaScript for the Flask editor UI.
|
|
4
|
+
"""JavaScript for the Flask editor UI.
|
|
5
|
+
|
|
6
|
+
DEPRECATED: This inline JavaScript module is kept for fallback compatibility only.
|
|
7
|
+
The JavaScript has been modularized into static/js/ directory:
|
|
8
|
+
- static/js/main.js (main entry point)
|
|
9
|
+
- static/js/core/ (state, api, utils)
|
|
10
|
+
- static/js/canvas/ (canvas, dragging, resize, selection)
|
|
11
|
+
- static/js/editor/ (preview, overlay, bbox, element-drag)
|
|
12
|
+
- static/js/alignment/ (basic, axis, distribute)
|
|
13
|
+
- static/js/shortcuts/ (keyboard, context-menu)
|
|
14
|
+
- static/js/ui/ (controls, download, help, theme)
|
|
15
|
+
|
|
16
|
+
To use static files (recommended):
|
|
17
|
+
Set USE_STATIC_FILES = True in templates/__init__.py
|
|
18
|
+
|
|
19
|
+
To use this inline version (fallback):
|
|
20
|
+
Set USE_STATIC_FILES = False in templates/__init__.py
|
|
21
|
+
"""
|
|
5
22
|
|
|
6
23
|
JS_SCRIPTS = """
|
|
7
24
|
let overrides = {{ overrides|safe }};
|
|
@@ -41,6 +58,56 @@ function isDarkMode() {
|
|
|
41
58
|
return document.documentElement.getAttribute('data-theme') === 'dark';
|
|
42
59
|
}
|
|
43
60
|
|
|
61
|
+
// Calculate actual rendered image dimensions when using object-fit: contain
|
|
62
|
+
// Returns: {renderedWidth, renderedHeight, offsetX, offsetY, containerWidth, containerHeight}
|
|
63
|
+
function getObjectFitContainDimensions(img) {
|
|
64
|
+
const containerWidth = img.offsetWidth;
|
|
65
|
+
const containerHeight = img.offsetHeight;
|
|
66
|
+
const naturalWidth = img.naturalWidth;
|
|
67
|
+
const naturalHeight = img.naturalHeight;
|
|
68
|
+
|
|
69
|
+
// Handle edge cases
|
|
70
|
+
if (!naturalWidth || !naturalHeight || !containerWidth || !containerHeight) {
|
|
71
|
+
return {
|
|
72
|
+
renderedWidth: containerWidth,
|
|
73
|
+
renderedHeight: containerHeight,
|
|
74
|
+
offsetX: 0,
|
|
75
|
+
offsetY: 0,
|
|
76
|
+
containerWidth,
|
|
77
|
+
containerHeight
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Calculate scale factor for object-fit: contain
|
|
82
|
+
const containerRatio = containerWidth / containerHeight;
|
|
83
|
+
const imageRatio = naturalWidth / naturalHeight;
|
|
84
|
+
|
|
85
|
+
let renderedWidth, renderedHeight, offsetX, offsetY;
|
|
86
|
+
|
|
87
|
+
if (imageRatio > containerRatio) {
|
|
88
|
+
// Image is wider than container - fit to width, letterbox top/bottom
|
|
89
|
+
renderedWidth = containerWidth;
|
|
90
|
+
renderedHeight = containerWidth / imageRatio;
|
|
91
|
+
offsetX = 0;
|
|
92
|
+
offsetY = (containerHeight - renderedHeight) / 2;
|
|
93
|
+
} else {
|
|
94
|
+
// Image is taller than container - fit to height, letterbox left/right
|
|
95
|
+
renderedHeight = containerHeight;
|
|
96
|
+
renderedWidth = containerHeight * imageRatio;
|
|
97
|
+
offsetX = (containerWidth - renderedWidth) / 2;
|
|
98
|
+
offsetY = 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
renderedWidth,
|
|
103
|
+
renderedHeight,
|
|
104
|
+
offsetX,
|
|
105
|
+
offsetY,
|
|
106
|
+
containerWidth,
|
|
107
|
+
containerHeight
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
44
111
|
// Hitmap-based element detection
|
|
45
112
|
let hitmapCanvas = null;
|
|
46
113
|
let hitmapCtx = null;
|
|
@@ -1353,6 +1420,8 @@ function toggleTheme() {
|
|
|
1353
1420
|
html.setAttribute('data-theme', next);
|
|
1354
1421
|
document.getElementById('theme-icon').innerHTML = next === 'dark' ? '☾' : '☼';
|
|
1355
1422
|
localStorage.setItem('scitex-editor-theme', next);
|
|
1423
|
+
// Re-render single panel preview with dark/light mode colors (if visible)
|
|
1424
|
+
updatePreview(true);
|
|
1356
1425
|
}
|
|
1357
1426
|
|
|
1358
1427
|
// Collapsible sections
|
|
@@ -1632,14 +1701,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1632
1701
|
// =============================================================================
|
|
1633
1702
|
// Loading Helpers
|
|
1634
1703
|
// =============================================================================
|
|
1635
|
-
function showLoading() {
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1704
|
+
function showLoading(message = 'Loading...') {
|
|
1705
|
+
const globalOverlay = document.getElementById('global-loading-overlay');
|
|
1706
|
+
const localOverlay = document.getElementById('loading-overlay');
|
|
1707
|
+
if (globalOverlay) {
|
|
1708
|
+
globalOverlay.style.display = 'flex';
|
|
1709
|
+
const loadingText = globalOverlay.querySelector('.loading-text');
|
|
1710
|
+
if (loadingText) loadingText.textContent = message;
|
|
1711
|
+
}
|
|
1712
|
+
if (localOverlay) localOverlay.style.display = 'flex';
|
|
1638
1713
|
}
|
|
1639
1714
|
|
|
1640
1715
|
function hideLoading() {
|
|
1641
|
-
const
|
|
1642
|
-
|
|
1716
|
+
const globalOverlay = document.getElementById('global-loading-overlay');
|
|
1717
|
+
const localOverlay = document.getElementById('loading-overlay');
|
|
1718
|
+
if (globalOverlay) globalOverlay.style.display = 'none';
|
|
1719
|
+
if (localOverlay) localOverlay.style.display = 'none';
|
|
1643
1720
|
}
|
|
1644
1721
|
|
|
1645
1722
|
// Update form controls from overrides (used when switching panels)
|
|
@@ -1793,8 +1870,12 @@ async function loadPanelGrid() {
|
|
|
1793
1870
|
|
|
1794
1871
|
console.log('Loading panel canvas for', panelData.panels.length, 'panels');
|
|
1795
1872
|
|
|
1796
|
-
//
|
|
1797
|
-
document.getElementById('preview-header').style.display = '
|
|
1873
|
+
// Hide single-panel preview completely for multi-panel bundles (unified canvas only)
|
|
1874
|
+
document.getElementById('preview-header').style.display = 'none';
|
|
1875
|
+
const previewWrapper = document.querySelector('.preview-wrapper');
|
|
1876
|
+
if (previewWrapper) {
|
|
1877
|
+
previewWrapper.style.display = 'none';
|
|
1878
|
+
}
|
|
1798
1879
|
|
|
1799
1880
|
// Fetch all panel images with bboxes
|
|
1800
1881
|
try {
|
|
@@ -1809,12 +1890,29 @@ async function loadPanelGrid() {
|
|
|
1809
1890
|
const canvasEl = document.getElementById('panel-canvas');
|
|
1810
1891
|
canvasEl.innerHTML = '';
|
|
1811
1892
|
|
|
1812
|
-
//
|
|
1813
|
-
const
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1893
|
+
// Use figz layout to position panels as unified canvas (matching export)
|
|
1894
|
+
const hasLayout = data.layout && Object.keys(data.layout).length > 0;
|
|
1895
|
+
|
|
1896
|
+
// Calculate scale factor: convert mm to pixels
|
|
1897
|
+
// Find total figure dimensions from layout
|
|
1898
|
+
let maxX = 0, maxY = 0;
|
|
1899
|
+
if (hasLayout) {
|
|
1900
|
+
Object.values(data.layout).forEach(l => {
|
|
1901
|
+
const right = (l.position?.x_mm || 0) + (l.size?.width_mm || 80);
|
|
1902
|
+
const bottom = (l.position?.y_mm || 0) + (l.size?.height_mm || 50);
|
|
1903
|
+
maxX = Math.max(maxX, right);
|
|
1904
|
+
maxY = Math.max(maxY, bottom);
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Scale to fit canvas (max width ~700px for good display)
|
|
1909
|
+
const canvasMaxWidth = 700;
|
|
1910
|
+
const scale = hasLayout && maxX > 0 ? canvasMaxWidth / maxX : 3; // ~3px per mm fallback
|
|
1911
|
+
canvasScale = scale; // Store globally for drag conversions
|
|
1912
|
+
|
|
1913
|
+
// Reset layout tracking
|
|
1914
|
+
panelLayoutMm = {};
|
|
1915
|
+
layoutModified = false;
|
|
1818
1916
|
|
|
1819
1917
|
data.panels.forEach((panel, idx) => {
|
|
1820
1918
|
// Store bboxes and imgSize in cache for interactive hover/click
|
|
@@ -1828,18 +1926,42 @@ async function loadPanelGrid() {
|
|
|
1828
1926
|
console.warn(`Panel ${panel.name}: missing bboxes or img_size`, {bboxes: !!panel.bboxes, img_size: !!panel.img_size});
|
|
1829
1927
|
}
|
|
1830
1928
|
|
|
1831
|
-
//
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1929
|
+
// Use figz layout for positioning (unified canvas like export)
|
|
1930
|
+
let pos, posMm;
|
|
1931
|
+
if (panel.layout && panel.layout.position && panel.layout.size) {
|
|
1932
|
+
const x_mm = panel.layout.position.x_mm || 0;
|
|
1933
|
+
const y_mm = panel.layout.position.y_mm || 0;
|
|
1934
|
+
const width_mm = panel.layout.size.width_mm || 80;
|
|
1935
|
+
const height_mm = panel.layout.size.height_mm || 50;
|
|
1936
|
+
pos = {
|
|
1937
|
+
x: x_mm * scale,
|
|
1938
|
+
y: y_mm * scale,
|
|
1939
|
+
width: width_mm * scale,
|
|
1940
|
+
height: height_mm * scale,
|
|
1941
|
+
};
|
|
1942
|
+
posMm = { x_mm, y_mm, width_mm, height_mm };
|
|
1943
|
+
} else {
|
|
1944
|
+
// Fallback grid layout if no figz layout
|
|
1945
|
+
const cols = Math.ceil(Math.sqrt(data.panels.length));
|
|
1946
|
+
const baseWidth = 220, baseHeight = 180, padding = 15;
|
|
1947
|
+
const col = idx % cols;
|
|
1948
|
+
const row = Math.floor(idx / cols);
|
|
1949
|
+
pos = {
|
|
1836
1950
|
x: padding + col * (baseWidth + padding),
|
|
1837
1951
|
y: padding + row * (baseHeight + padding),
|
|
1838
1952
|
width: baseWidth,
|
|
1839
1953
|
height: baseHeight,
|
|
1840
1954
|
};
|
|
1955
|
+
// Convert to mm for fallback
|
|
1956
|
+
posMm = {
|
|
1957
|
+
x_mm: pos.x / scale,
|
|
1958
|
+
y_mm: pos.y / scale,
|
|
1959
|
+
width_mm: pos.width / scale,
|
|
1960
|
+
height_mm: pos.height / scale,
|
|
1961
|
+
};
|
|
1841
1962
|
}
|
|
1842
|
-
|
|
1963
|
+
panelPositions[panel.name] = pos;
|
|
1964
|
+
panelLayoutMm[panel.name] = posMm;
|
|
1843
1965
|
|
|
1844
1966
|
const item = document.createElement('div');
|
|
1845
1967
|
item.className = 'panel-canvas-item' + (idx === currentPanelIndex ? ' active' : '');
|
|
@@ -1852,43 +1974,44 @@ async function loadPanelGrid() {
|
|
|
1852
1974
|
|
|
1853
1975
|
if (panel.image) {
|
|
1854
1976
|
item.innerHTML = `
|
|
1855
|
-
<span class="panel-canvas-label"
|
|
1977
|
+
<span class="panel-canvas-label">${panel.name}</span>
|
|
1978
|
+
<span class="panel-position-indicator" id="pos-${panel.name}"></span>
|
|
1979
|
+
<div class="panel-drag-handle" title="Drag to move panel">⋮⋮</div>
|
|
1856
1980
|
<div class="panel-card-container">
|
|
1857
1981
|
<img src="data:image/png;base64,${panel.image}" alt="Panel ${panel.name}">
|
|
1858
1982
|
<svg class="panel-card-overlay" id="panel-overlay-${idx}"></svg>
|
|
1859
1983
|
</div>
|
|
1860
|
-
<div class="panel-canvas-resize"></div>
|
|
1861
1984
|
`;
|
|
1862
1985
|
} else {
|
|
1863
1986
|
item.innerHTML = `
|
|
1864
|
-
<span class="panel-canvas-label"
|
|
1987
|
+
<span class="panel-canvas-label">${panel.name}</span>
|
|
1988
|
+
<span class="panel-position-indicator" id="pos-${panel.name}"></span>
|
|
1989
|
+
<div class="panel-drag-handle" title="Drag to move panel">⋮⋮</div>
|
|
1865
1990
|
<div style="padding: 20px; color: var(--text-muted);">No preview</div>
|
|
1866
1991
|
`;
|
|
1867
1992
|
}
|
|
1868
1993
|
|
|
1869
|
-
// Add interactive event handlers
|
|
1994
|
+
// Add interactive event handlers (hover, click for element selection)
|
|
1870
1995
|
initCanvasItemInteraction(item, idx, panel.name);
|
|
1871
1996
|
|
|
1997
|
+
// Add drag handler for panel repositioning
|
|
1998
|
+
initPanelDrag(item, panel.name);
|
|
1999
|
+
|
|
1872
2000
|
canvasEl.appendChild(item);
|
|
1873
2001
|
});
|
|
1874
2002
|
|
|
1875
|
-
// Update canvas
|
|
1876
|
-
const
|
|
1877
|
-
|
|
2003
|
+
// Update canvas size to fit all panels (unified canvas)
|
|
2004
|
+
const canvasHeight = Math.max(...Object.values(panelPositions).map(p => p.y + p.height)) + 10;
|
|
2005
|
+
const canvasWidth = Math.max(...Object.values(panelPositions).map(p => p.x + p.width)) + 10;
|
|
2006
|
+
canvasEl.style.minHeight = Math.max(400, canvasHeight) + 'px';
|
|
2007
|
+
canvasEl.style.minWidth = canvasWidth + 'px';
|
|
1878
2008
|
|
|
1879
2009
|
// Update panel indicator
|
|
1880
2010
|
updatePanelIndicator();
|
|
1881
2011
|
|
|
1882
|
-
// Show canvas for multi-panel figures
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
document.getElementById('panel-grid-section').style.display = 'block';
|
|
1886
|
-
// Hide single-panel preview for multi-panel bundles
|
|
1887
|
-
const previewWrapper = document.querySelector('.preview-wrapper');
|
|
1888
|
-
if (previewWrapper) {
|
|
1889
|
-
previewWrapper.style.display = 'none';
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
2012
|
+
// Show unified canvas for multi-panel figures
|
|
2013
|
+
showingPanelGrid = true;
|
|
2014
|
+
document.getElementById('panel-grid-section').style.display = 'block';
|
|
1892
2015
|
} catch (e) {
|
|
1893
2016
|
console.error('Error loading panels:', e);
|
|
1894
2017
|
}
|
|
@@ -1911,25 +2034,43 @@ function initCanvasItemInteraction(item, panelIdx, panelName) {
|
|
|
1911
2034
|
overlay.style.height = img.offsetHeight + 'px';
|
|
1912
2035
|
});
|
|
1913
2036
|
|
|
1914
|
-
// Mousemove for hover detection
|
|
2037
|
+
// Mousemove for hover detection (accounting for object-fit:contain letterboxing)
|
|
1915
2038
|
container.addEventListener('mousemove', (e) => {
|
|
1916
2039
|
const panelCache = panelBboxesCache[panelName];
|
|
1917
2040
|
if (!panelCache) return;
|
|
1918
2041
|
|
|
1919
2042
|
const rect = img.getBoundingClientRect();
|
|
2043
|
+
const dims = getObjectFitContainDimensions(img);
|
|
2044
|
+
|
|
2045
|
+
// Mouse position relative to container
|
|
1920
2046
|
const x = e.clientX - rect.left;
|
|
1921
2047
|
const y = e.clientY - rect.top;
|
|
1922
2048
|
|
|
1923
|
-
|
|
1924
|
-
const
|
|
1925
|
-
const
|
|
1926
|
-
|
|
2049
|
+
// Adjust for letterbox offset to get position relative to actual rendered image
|
|
2050
|
+
const imgRelX = x - dims.offsetX;
|
|
2051
|
+
const imgRelY = y - dims.offsetY;
|
|
2052
|
+
|
|
2053
|
+
// Check if click is within rendered image bounds
|
|
2054
|
+
if (imgRelX < 0 || imgRelY < 0 || imgRelX > dims.renderedWidth || imgRelY > dims.renderedHeight) {
|
|
2055
|
+
// Outside rendered image area (in letterbox region)
|
|
2056
|
+
if (panelHoveredElement !== null) {
|
|
2057
|
+
panelHoveredElement = null;
|
|
2058
|
+
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null, img);
|
|
2059
|
+
}
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// Scale to original image coordinates
|
|
2064
|
+
const scaleX = panelCache.imgSize.width / dims.renderedWidth;
|
|
2065
|
+
const scaleY = panelCache.imgSize.height / dims.renderedHeight;
|
|
2066
|
+
const imgX = imgRelX * scaleX;
|
|
2067
|
+
const imgY = imgRelY * scaleY;
|
|
1927
2068
|
|
|
1928
2069
|
const element = findElementInPanelAt(imgX, imgY, panelCache.bboxes);
|
|
1929
2070
|
if (element !== panelHoveredElement || activePanelCard !== item) {
|
|
1930
2071
|
panelHoveredElement = element;
|
|
1931
2072
|
activePanelCard = item;
|
|
1932
|
-
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null);
|
|
2073
|
+
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null, img);
|
|
1933
2074
|
}
|
|
1934
2075
|
});
|
|
1935
2076
|
|
|
@@ -1937,11 +2078,22 @@ function initCanvasItemInteraction(item, panelIdx, panelName) {
|
|
|
1937
2078
|
container.addEventListener('mouseleave', () => {
|
|
1938
2079
|
panelHoveredElement = null;
|
|
1939
2080
|
if (activePanelCard === item) {
|
|
1940
|
-
updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null);
|
|
2081
|
+
updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null, null);
|
|
1941
2082
|
}
|
|
1942
2083
|
});
|
|
1943
2084
|
|
|
1944
|
-
//
|
|
2085
|
+
// Mousedown to start element drag (ONLY for legends and panel letters)
|
|
2086
|
+
container.addEventListener('mousedown', (e) => {
|
|
2087
|
+
const panelCache = panelBboxesCache[panelName];
|
|
2088
|
+
if (!panelCache || !panelHoveredElement) return;
|
|
2089
|
+
|
|
2090
|
+
// Only allow dragging of legends and panel letters (scientific rigor)
|
|
2091
|
+
if (isDraggableElement(panelHoveredElement, panelCache.bboxes)) {
|
|
2092
|
+
startElementDrag(e, panelHoveredElement, panelName, img, panelCache.bboxes);
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
// Click to select element (accounting for object-fit:contain letterboxing)
|
|
1945
2097
|
container.addEventListener('click', (e) => {
|
|
1946
2098
|
e.stopPropagation();
|
|
1947
2099
|
|
|
@@ -1951,16 +2103,30 @@ function initCanvasItemInteraction(item, panelIdx, panelName) {
|
|
|
1951
2103
|
|
|
1952
2104
|
if (panelCache && img) {
|
|
1953
2105
|
const rect = img.getBoundingClientRect();
|
|
2106
|
+
const dims = getObjectFitContainDimensions(img);
|
|
2107
|
+
|
|
2108
|
+
// Mouse position relative to container
|
|
1954
2109
|
const x = e.clientX - rect.left;
|
|
1955
2110
|
const y = e.clientY - rect.top;
|
|
1956
2111
|
|
|
1957
|
-
|
|
1958
|
-
const
|
|
1959
|
-
const
|
|
1960
|
-
const imgY = y * scaleY;
|
|
2112
|
+
// Adjust for letterbox offset
|
|
2113
|
+
const imgRelX = x - dims.offsetX;
|
|
2114
|
+
const imgRelY = y - dims.offsetY;
|
|
1961
2115
|
|
|
1962
|
-
|
|
1963
|
-
|
|
2116
|
+
// Check if click is within rendered image bounds
|
|
2117
|
+
if (imgRelX >= 0 && imgRelY >= 0 && imgRelX <= dims.renderedWidth && imgRelY <= dims.renderedHeight) {
|
|
2118
|
+
// Scale to original image coordinates
|
|
2119
|
+
const scaleX = panelCache.imgSize.width / dims.renderedWidth;
|
|
2120
|
+
const scaleY = panelCache.imgSize.height / dims.renderedHeight;
|
|
2121
|
+
const imgX = imgRelX * scaleX;
|
|
2122
|
+
const imgY = imgRelY * scaleY;
|
|
2123
|
+
|
|
2124
|
+
clickedElement = findElementInPanelAt(imgX, imgY, panelCache.bboxes);
|
|
2125
|
+
console.log(`Click at (${imgX.toFixed(0)}, ${imgY.toFixed(0)}) -> element: ${clickedElement}`);
|
|
2126
|
+
} else {
|
|
2127
|
+
clickedElement = null;
|
|
2128
|
+
console.log('Click outside rendered image bounds (in letterbox area)');
|
|
2129
|
+
}
|
|
1964
2130
|
}
|
|
1965
2131
|
|
|
1966
2132
|
if (clickedElement) {
|
|
@@ -2017,26 +2183,44 @@ function initPanelCardInteraction(card, panelIdx, panelName) {
|
|
|
2017
2183
|
overlay.style.height = img.offsetHeight + 'px';
|
|
2018
2184
|
});
|
|
2019
2185
|
|
|
2020
|
-
// Mousemove for hover detection
|
|
2186
|
+
// Mousemove for hover detection (accounting for object-fit:contain letterboxing)
|
|
2021
2187
|
container.addEventListener('mousemove', (e) => {
|
|
2022
2188
|
const panelCache = panelBboxesCache[panelName];
|
|
2023
2189
|
if (!panelCache) return;
|
|
2024
2190
|
|
|
2025
2191
|
const rect = img.getBoundingClientRect();
|
|
2192
|
+
const dims = getObjectFitContainDimensions(img);
|
|
2193
|
+
|
|
2194
|
+
// Mouse position relative to container
|
|
2026
2195
|
const x = e.clientX - rect.left;
|
|
2027
2196
|
const y = e.clientY - rect.top;
|
|
2028
2197
|
|
|
2029
|
-
|
|
2030
|
-
const
|
|
2031
|
-
const
|
|
2032
|
-
|
|
2198
|
+
// Adjust for letterbox offset to get position relative to actual rendered image
|
|
2199
|
+
const imgRelX = x - dims.offsetX;
|
|
2200
|
+
const imgRelY = y - dims.offsetY;
|
|
2201
|
+
|
|
2202
|
+
// Check if mouse is within rendered image bounds
|
|
2203
|
+
if (imgRelX < 0 || imgRelY < 0 || imgRelX > dims.renderedWidth || imgRelY > dims.renderedHeight) {
|
|
2204
|
+
// Outside rendered image area (in letterbox region)
|
|
2205
|
+
if (panelHoveredElement !== null) {
|
|
2206
|
+
panelHoveredElement = null;
|
|
2207
|
+
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null, img);
|
|
2208
|
+
}
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// Scale to original image coordinates
|
|
2213
|
+
const scaleX = panelCache.imgSize.width / dims.renderedWidth;
|
|
2214
|
+
const scaleY = panelCache.imgSize.height / dims.renderedHeight;
|
|
2215
|
+
const imgX = imgRelX * scaleX;
|
|
2216
|
+
const imgY = imgRelY * scaleY;
|
|
2033
2217
|
|
|
2034
2218
|
// Find element at cursor using panel's bboxes
|
|
2035
2219
|
const element = findElementInPanelAt(imgX, imgY, panelCache.bboxes);
|
|
2036
2220
|
if (element !== panelHoveredElement || activePanelCard !== card) {
|
|
2037
2221
|
panelHoveredElement = element;
|
|
2038
2222
|
activePanelCard = card;
|
|
2039
|
-
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null);
|
|
2223
|
+
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, panelHoveredElement, null, img);
|
|
2040
2224
|
}
|
|
2041
2225
|
});
|
|
2042
2226
|
|
|
@@ -2044,7 +2228,7 @@ function initPanelCardInteraction(card, panelIdx, panelName) {
|
|
|
2044
2228
|
container.addEventListener('mouseleave', () => {
|
|
2045
2229
|
panelHoveredElement = null;
|
|
2046
2230
|
if (activePanelCard === card) {
|
|
2047
|
-
updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null);
|
|
2231
|
+
updatePanelOverlay(overlay, {}, {width: 0, height: 0}, 0, 0, null, null, null);
|
|
2048
2232
|
}
|
|
2049
2233
|
});
|
|
2050
2234
|
|
|
@@ -2175,29 +2359,55 @@ function redrawAllPanelOverlays() {
|
|
|
2175
2359
|
const rect = img.getBoundingClientRect();
|
|
2176
2360
|
console.log(`Redraw panel ${panelName}: rect=${rect.width}x${rect.height}, bboxes=${Object.keys(panelCache.bboxes).length}`);
|
|
2177
2361
|
if (rect.width > 0 && rect.height > 0) {
|
|
2178
|
-
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null);
|
|
2362
|
+
updatePanelOverlay(overlay, panelCache.bboxes, panelCache.imgSize, rect.width, rect.height, null, null, img);
|
|
2179
2363
|
}
|
|
2180
2364
|
});
|
|
2181
2365
|
}
|
|
2182
2366
|
|
|
2183
2367
|
// Update SVG overlay for a panel card
|
|
2184
|
-
|
|
2185
|
-
|
|
2368
|
+
// img: the img element (to calculate object-fit:contain dimensions)
|
|
2369
|
+
// OR pass null with displayWidth/displayHeight for backward compatibility
|
|
2370
|
+
function updatePanelOverlay(overlay, bboxes, imgSizePanel, displayWidth, displayHeight, hovered, selected, img) {
|
|
2371
|
+
if (!overlay || !imgSizePanel || imgSizePanel.width === 0) {
|
|
2186
2372
|
if (overlay) overlay.innerHTML = '';
|
|
2187
2373
|
return;
|
|
2188
2374
|
}
|
|
2189
2375
|
|
|
2190
|
-
|
|
2191
|
-
|
|
2376
|
+
// Calculate actual rendered dimensions accounting for object-fit: contain
|
|
2377
|
+
let renderedWidth, renderedHeight, offsetX, offsetY;
|
|
2378
|
+
if (img) {
|
|
2379
|
+
const dims = getObjectFitContainDimensions(img);
|
|
2380
|
+
renderedWidth = dims.renderedWidth;
|
|
2381
|
+
renderedHeight = dims.renderedHeight;
|
|
2382
|
+
offsetX = dims.offsetX;
|
|
2383
|
+
offsetY = dims.offsetY;
|
|
2384
|
+
// Use container dimensions for the overlay size
|
|
2385
|
+
overlay.setAttribute('width', dims.containerWidth);
|
|
2386
|
+
overlay.setAttribute('height', dims.containerHeight);
|
|
2387
|
+
overlay.style.width = dims.containerWidth + 'px';
|
|
2388
|
+
overlay.style.height = dims.containerHeight + 'px';
|
|
2389
|
+
} else {
|
|
2390
|
+
// Fallback for backward compatibility
|
|
2391
|
+
if (displayWidth === 0 || displayHeight === 0) {
|
|
2392
|
+
if (overlay) overlay.innerHTML = '';
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
renderedWidth = displayWidth;
|
|
2396
|
+
renderedHeight = displayHeight;
|
|
2397
|
+
offsetX = 0;
|
|
2398
|
+
offsetY = 0;
|
|
2399
|
+
overlay.setAttribute('width', displayWidth);
|
|
2400
|
+
overlay.setAttribute('height', displayHeight);
|
|
2401
|
+
}
|
|
2192
2402
|
|
|
2193
|
-
const scaleX =
|
|
2194
|
-
const scaleY =
|
|
2403
|
+
const scaleX = renderedWidth / imgSizePanel.width;
|
|
2404
|
+
const scaleY = renderedHeight / imgSizePanel.height;
|
|
2195
2405
|
|
|
2196
2406
|
let svg = '';
|
|
2197
2407
|
|
|
2198
|
-
// Debug mode: draw all bboxes
|
|
2408
|
+
// Debug mode: draw all bboxes (with offset for object-fit:contain letterboxing)
|
|
2199
2409
|
if (panelDebugMode && bboxes) {
|
|
2200
|
-
svg += drawPanelDebugBboxes(bboxes, scaleX, scaleY);
|
|
2410
|
+
svg += drawPanelDebugBboxes(bboxes, scaleX, scaleY, offsetX, offsetY);
|
|
2201
2411
|
}
|
|
2202
2412
|
|
|
2203
2413
|
function drawPanelElement(elementName, type) {
|
|
@@ -2207,35 +2417,35 @@ function updatePanelOverlay(overlay, bboxes, imgSizePanel, displayWidth, display
|
|
|
2207
2417
|
const elementType = bbox.element_type || '';
|
|
2208
2418
|
const hasPoints = bbox.points && bbox.points.length > 0;
|
|
2209
2419
|
|
|
2210
|
-
// Lines - draw as path
|
|
2420
|
+
// Lines - draw as path (with offset)
|
|
2211
2421
|
if ((elementType === 'line' || elementName.includes('trace_')) && hasPoints) {
|
|
2212
2422
|
if (bbox.points.length < 2) return '';
|
|
2213
2423
|
const points = bbox.points.filter(pt => Array.isArray(pt) && pt.length >= 2);
|
|
2214
2424
|
if (points.length < 2) return '';
|
|
2215
2425
|
|
|
2216
|
-
let pathD = `M ${points[0][0] * scaleX} ${points[0][1] * scaleY}`;
|
|
2426
|
+
let pathD = `M ${points[0][0] * scaleX + offsetX} ${points[0][1] * scaleY + offsetY}`;
|
|
2217
2427
|
for (let i = 1; i < points.length; i++) {
|
|
2218
|
-
pathD += ` L ${points[i][0] * scaleX} ${points[i][1] * scaleY}`;
|
|
2428
|
+
pathD += ` L ${points[i][0] * scaleX + offsetX} ${points[i][1] * scaleY + offsetY}`;
|
|
2219
2429
|
}
|
|
2220
2430
|
|
|
2221
2431
|
const className = type === 'hover' ? 'hover-path' : 'selected-path';
|
|
2222
2432
|
return `<path class="${className}" d="${pathD}"/>`;
|
|
2223
2433
|
}
|
|
2224
|
-
// Scatter - draw as circles
|
|
2434
|
+
// Scatter - draw as circles (with offset)
|
|
2225
2435
|
else if (elementType === 'scatter' && hasPoints) {
|
|
2226
2436
|
const className = type === 'hover' ? 'hover-scatter' : 'selected-scatter';
|
|
2227
2437
|
let result = '';
|
|
2228
2438
|
for (const pt of bbox.points) {
|
|
2229
2439
|
if (!Array.isArray(pt) || pt.length < 2) continue;
|
|
2230
|
-
result += `<circle class="${className}" cx="${pt[0] * scaleX}" cy="${pt[1] * scaleY}" r="3"/>`;
|
|
2440
|
+
result += `<circle class="${className}" cx="${pt[0] * scaleX + offsetX}" cy="${pt[1] * scaleY + offsetY}" r="3"/>`;
|
|
2231
2441
|
}
|
|
2232
2442
|
return result;
|
|
2233
2443
|
}
|
|
2234
|
-
// Default - draw bbox rectangle
|
|
2444
|
+
// Default - draw bbox rectangle (with offset)
|
|
2235
2445
|
else {
|
|
2236
2446
|
const rectClass = type === 'hover' ? 'hover-rect' : 'selected-rect';
|
|
2237
|
-
const x = bbox.x0 * scaleX - 1;
|
|
2238
|
-
const y = bbox.y0 * scaleY - 1;
|
|
2447
|
+
const x = bbox.x0 * scaleX + offsetX - 1;
|
|
2448
|
+
const y = bbox.y0 * scaleY + offsetY - 1;
|
|
2239
2449
|
const w = (bbox.x1 - bbox.x0) * scaleX + 2;
|
|
2240
2450
|
const h = (bbox.y1 - bbox.y0) * scaleY + 2;
|
|
2241
2451
|
return `<rect class="${rectClass}" x="${x}" y="${y}" width="${w}" height="${h}" rx="2"/>`;
|
|
@@ -2253,10 +2463,13 @@ function updatePanelOverlay(overlay, bboxes, imgSizePanel, displayWidth, display
|
|
|
2253
2463
|
overlay.innerHTML = svg;
|
|
2254
2464
|
}
|
|
2255
2465
|
|
|
2256
|
-
// Draw all bboxes for a panel in debug mode
|
|
2257
|
-
function drawPanelDebugBboxes(bboxes, scaleX, scaleY) {
|
|
2466
|
+
// Draw all bboxes for a panel in debug mode (with offset for object-fit:contain)
|
|
2467
|
+
function drawPanelDebugBboxes(bboxes, scaleX, scaleY, offsetX, offsetY) {
|
|
2258
2468
|
let svg = '';
|
|
2259
2469
|
let count = 0;
|
|
2470
|
+
// Default offset to 0 if not provided
|
|
2471
|
+
offsetX = offsetX || 0;
|
|
2472
|
+
offsetY = offsetY || 0;
|
|
2260
2473
|
|
|
2261
2474
|
for (const [name, bbox] of Object.entries(bboxes)) {
|
|
2262
2475
|
if (name === '_meta') continue;
|
|
@@ -2276,9 +2489,9 @@ function drawPanelDebugBboxes(bboxes, scaleX, scaleY) {
|
|
|
2276
2489
|
rectClass = 'debug-rect-trace';
|
|
2277
2490
|
}
|
|
2278
2491
|
|
|
2279
|
-
// Draw bbox rectangle
|
|
2280
|
-
const x = bbox.x0 * scaleX;
|
|
2281
|
-
const y = bbox.y0 * scaleY;
|
|
2492
|
+
// Draw bbox rectangle (with offset for object-fit:contain letterboxing)
|
|
2493
|
+
const x = bbox.x0 * scaleX + offsetX;
|
|
2494
|
+
const y = bbox.y0 * scaleY + offsetY;
|
|
2282
2495
|
const w = (bbox.x1 - bbox.x0) * scaleX;
|
|
2283
2496
|
const h = (bbox.y1 - bbox.y0) * scaleY;
|
|
2284
2497
|
|
|
@@ -2288,13 +2501,13 @@ function drawPanelDebugBboxes(bboxes, scaleX, scaleY) {
|
|
|
2288
2501
|
const shortName = name.length > 10 ? name.substring(0, 8) + '..' : name;
|
|
2289
2502
|
svg += `<text class="debug-label" x="${x + 1}" y="${y + 8}" style="font-size: 6px;">${shortName}</text>`;
|
|
2290
2503
|
|
|
2291
|
-
// Draw path points if available
|
|
2504
|
+
// Draw path points if available (with offset)
|
|
2292
2505
|
if (hasPoints && bbox.points.length > 1) {
|
|
2293
|
-
let pathD = `M ${bbox.points[0][0] * scaleX} ${bbox.points[0][1] * scaleY}`;
|
|
2506
|
+
let pathD = `M ${bbox.points[0][0] * scaleX + offsetX} ${bbox.points[0][1] * scaleY + offsetY}`;
|
|
2294
2507
|
for (let i = 1; i < bbox.points.length; i++) {
|
|
2295
2508
|
const pt = bbox.points[i];
|
|
2296
2509
|
if (pt && pt.length >= 2) {
|
|
2297
|
-
pathD += ` L ${pt[0] * scaleX} ${pt[1] * scaleY}`;
|
|
2510
|
+
pathD += ` L ${pt[0] * scaleX + offsetX} ${pt[1] * scaleY + offsetY}`;
|
|
2298
2511
|
}
|
|
2299
2512
|
}
|
|
2300
2513
|
svg += `<path class="debug-path" d="${pathD}"/>`;
|
|
@@ -2353,11 +2566,7 @@ async function loadPanelForEditing(panelIdx, panelName, elementToSelect) {
|
|
|
2353
2566
|
// Scroll to section and show properties
|
|
2354
2567
|
scrollToSection(selectedElement);
|
|
2355
2568
|
|
|
2356
|
-
//
|
|
2357
|
-
const previewWrapper = document.querySelector('.preview-wrapper');
|
|
2358
|
-
if (previewWrapper) {
|
|
2359
|
-
previewWrapper.style.display = 'block';
|
|
2360
|
-
}
|
|
2569
|
+
// Keep unified canvas view only - don't show single-panel preview
|
|
2361
2570
|
|
|
2362
2571
|
// Update panel path display in right panel header
|
|
2363
2572
|
const panelPathEl = document.getElementById('panel-path-display');
|
|
@@ -2377,14 +2586,8 @@ async function loadPanelForEditing(panelIdx, panelName, elementToSelect) {
|
|
|
2377
2586
|
function togglePanelGrid() {
|
|
2378
2587
|
showingPanelGrid = !showingPanelGrid;
|
|
2379
2588
|
const gridSection = document.getElementById('panel-grid-section');
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
if (showingPanelGrid) {
|
|
2383
|
-
gridSection.style.display = 'block';
|
|
2384
|
-
showBtn.textContent = 'Hide All';
|
|
2385
|
-
} else {
|
|
2386
|
-
gridSection.style.display = 'none';
|
|
2387
|
-
showBtn.textContent = 'Show All';
|
|
2589
|
+
if (gridSection) {
|
|
2590
|
+
gridSection.style.display = showingPanelGrid ? 'block' : 'none';
|
|
2388
2591
|
}
|
|
2389
2592
|
}
|
|
2390
2593
|
|
|
@@ -2471,21 +2674,24 @@ function updatePanelIndicator() {
|
|
|
2471
2674
|
const current = currentPanelIndex + 1;
|
|
2472
2675
|
const panelName = panelData.panels[currentPanelIndex];
|
|
2473
2676
|
|
|
2474
|
-
|
|
2475
|
-
document.getElementById('
|
|
2677
|
+
// Update indicator text (if elements exist)
|
|
2678
|
+
const indicatorEl = document.getElementById('panel-indicator');
|
|
2679
|
+
if (indicatorEl) indicatorEl.textContent = `${current} / ${total}`;
|
|
2476
2680
|
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
document.getElementById('next-panel-btn').disabled = currentPanelIndex === total - 1;
|
|
2681
|
+
const nameEl = document.getElementById('current-panel-name');
|
|
2682
|
+
if (nameEl) nameEl.textContent = `Panel ${panelName.replace('.pltz.d', '')}`;
|
|
2480
2683
|
}
|
|
2481
2684
|
|
|
2482
2685
|
// =============================================================================
|
|
2483
2686
|
// Canvas Mode (Draggable Panel Layout)
|
|
2484
2687
|
// =============================================================================
|
|
2485
2688
|
let canvasMode = 'grid'; // 'grid' or 'canvas'
|
|
2486
|
-
let panelPositions = {}; // Store panel positions {name: {x, y, width, height}}
|
|
2689
|
+
let panelPositions = {}; // Store panel positions {name: {x, y, width, height}} in pixels
|
|
2690
|
+
let panelLayoutMm = {}; // Store panel positions in mm for saving {name: {x_mm, y_mm, width_mm, height_mm}}
|
|
2691
|
+
let canvasScale = 3; // Scale factor: pixels per mm (updated in loadPanelGrid)
|
|
2487
2692
|
let draggedPanel = null;
|
|
2488
2693
|
let dragOffset = {x: 0, y: 0};
|
|
2694
|
+
let layoutModified = false; // Track if layout has been modified
|
|
2489
2695
|
|
|
2490
2696
|
function setCanvasMode(mode) {
|
|
2491
2697
|
canvasMode = mode;
|
|
@@ -2586,43 +2792,608 @@ async function renderPanelCanvas() {
|
|
|
2586
2792
|
}
|
|
2587
2793
|
}
|
|
2588
2794
|
|
|
2589
|
-
|
|
2795
|
+
// Check if an element is interactive (should not initiate drag)
|
|
2796
|
+
function isInteractiveElement(target) {
|
|
2797
|
+
// SVG paths with hover-path class are interactive elements
|
|
2798
|
+
if (target.classList && target.classList.contains('hover-path')) return true;
|
|
2799
|
+
if (target.classList && target.classList.contains('hit-path')) return true;
|
|
2800
|
+
|
|
2801
|
+
// Check parent elements for hover-path (click might be on child)
|
|
2802
|
+
let el = target;
|
|
2803
|
+
while (el && el !== document.body) {
|
|
2804
|
+
if (el.tagName === 'path' || el.tagName === 'PATH') {
|
|
2805
|
+
// Path elements in SVG overlay are interactive
|
|
2806
|
+
const svg = el.closest('svg');
|
|
2807
|
+
if (svg && svg.classList.contains('panel-card-overlay')) {
|
|
2808
|
+
return true;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
el = el.parentElement;
|
|
2812
|
+
}
|
|
2813
|
+
return false;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// =============================================================================
|
|
2817
|
+
// Element Dragging (Legends, Panel Letters)
|
|
2818
|
+
// =============================================================================
|
|
2819
|
+
let elementDragState = null; // {element, panelName, startPos, elementType, axId}
|
|
2820
|
+
|
|
2821
|
+
// Snap positions for draggable elements (normalized axes coordinates 0-1)
|
|
2822
|
+
const SNAP_POSITIONS = {
|
|
2823
|
+
'upper left': {x: 0.02, y: 0.98},
|
|
2824
|
+
'upper center': {x: 0.50, y: 0.98},
|
|
2825
|
+
'upper right': {x: 0.98, y: 0.98},
|
|
2826
|
+
'center left': {x: 0.02, y: 0.50},
|
|
2827
|
+
'center': {x: 0.50, y: 0.50},
|
|
2828
|
+
'center right': {x: 0.98, y: 0.50},
|
|
2829
|
+
'lower left': {x: 0.02, y: 0.02},
|
|
2830
|
+
'lower center': {x: 0.50, y: 0.02},
|
|
2831
|
+
'lower right': {x: 0.98, y: 0.02},
|
|
2832
|
+
};
|
|
2833
|
+
|
|
2834
|
+
// Check if an element is draggable
|
|
2835
|
+
// ONLY panel letters and legends are draggable to maintain scientific rigor
|
|
2836
|
+
// Data elements (lines, scatter, bars, etc.) must NOT be movable
|
|
2837
|
+
function isDraggableElement(elementName, bboxes) {
|
|
2838
|
+
if (!elementName || !bboxes) return false;
|
|
2839
|
+
|
|
2840
|
+
// Whitelist: ONLY these element types are draggable
|
|
2841
|
+
const DRAGGABLE_TYPES = ['legend', 'panel_letter'];
|
|
2842
|
+
|
|
2843
|
+
// Check by element_type in bbox info
|
|
2844
|
+
const info = bboxes[elementName];
|
|
2845
|
+
if (info && DRAGGABLE_TYPES.includes(info.element_type)) {
|
|
2846
|
+
return true;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// Check by naming convention (strict match)
|
|
2850
|
+
if (elementName.match(/_legend$/)) return true;
|
|
2851
|
+
if (elementName.match(/_panel_letter_[A-Z]$/)) return true;
|
|
2852
|
+
|
|
2853
|
+
// Everything else is NOT draggable (data integrity)
|
|
2854
|
+
return false;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// Start element drag (for legends and panel letters)
|
|
2858
|
+
function startElementDrag(e, elementName, panelName, img, bboxes) {
|
|
2859
|
+
const info = bboxes[elementName] || {};
|
|
2860
|
+
const elementType = info.element_type || (elementName.includes('legend') ? 'legend' : 'panel_letter');
|
|
2861
|
+
|
|
2862
|
+
// Extract ax_id from element name (e.g., "ax_00_legend" -> "ax_00")
|
|
2863
|
+
const axId = elementName.split('_').slice(0, 2).join('_');
|
|
2864
|
+
|
|
2865
|
+
// Get axes bbox for constraining drag
|
|
2866
|
+
const axesBbox = bboxes[`${axId}_panel`] || null;
|
|
2867
|
+
|
|
2868
|
+
elementDragState = {
|
|
2869
|
+
element: elementName,
|
|
2870
|
+
panelName: panelName,
|
|
2871
|
+
elementType: elementType,
|
|
2872
|
+
axId: axId,
|
|
2873
|
+
axesBbox: axesBbox,
|
|
2874
|
+
bboxes: bboxes,
|
|
2875
|
+
img: img,
|
|
2876
|
+
startMouseX: e.clientX,
|
|
2877
|
+
startMouseY: e.clientY,
|
|
2878
|
+
startBbox: {...info},
|
|
2879
|
+
};
|
|
2880
|
+
|
|
2881
|
+
// Show snap guide overlay
|
|
2882
|
+
showSnapGuides(img, axesBbox, bboxes);
|
|
2883
|
+
|
|
2884
|
+
document.addEventListener('mousemove', onElementDrag);
|
|
2885
|
+
document.addEventListener('mouseup', stopElementDrag);
|
|
2886
|
+
|
|
2590
2887
|
e.preventDefault();
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2888
|
+
e.stopPropagation();
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
// Handle element drag movement
|
|
2892
|
+
function onElementDrag(e) {
|
|
2893
|
+
if (!elementDragState) return;
|
|
2894
|
+
|
|
2895
|
+
const {img, bboxes, element, axId, axesBbox, startBbox, startMouseX, startMouseY} = elementDragState;
|
|
2896
|
+
if (!img) return;
|
|
2897
|
+
|
|
2898
|
+
const rect = img.getBoundingClientRect();
|
|
2899
|
+
const dims = getObjectFitContainDimensions(img);
|
|
2900
|
+
|
|
2901
|
+
// Calculate delta in image coordinates
|
|
2902
|
+
const deltaX = e.clientX - startMouseX;
|
|
2903
|
+
const deltaY = e.clientY - startMouseY;
|
|
2904
|
+
|
|
2905
|
+
// Convert to image pixel coordinates
|
|
2906
|
+
const scaleX = dims.renderedWidth / rect.width;
|
|
2907
|
+
const scaleY = dims.renderedHeight / rect.height;
|
|
2908
|
+
const imgDeltaX = deltaX * scaleX * (bboxes._meta?.imgSize?.width || 1) / dims.renderedWidth;
|
|
2909
|
+
const imgDeltaY = deltaY * scaleY * (bboxes._meta?.imgSize?.height || 1) / dims.renderedHeight;
|
|
2910
|
+
|
|
2911
|
+
// Update bbox position (for visual feedback)
|
|
2912
|
+
if (bboxes[element]) {
|
|
2913
|
+
const newX0 = startBbox.x0 + imgDeltaX;
|
|
2914
|
+
const newY0 = startBbox.y0 + imgDeltaY;
|
|
2915
|
+
bboxes[element].x0 = newX0;
|
|
2916
|
+
bboxes[element].y0 = newY0;
|
|
2917
|
+
bboxes[element].x1 = newX0 + (startBbox.x1 - startBbox.x0);
|
|
2918
|
+
bboxes[element].y1 = newY0 + (startBbox.y1 - startBbox.y0);
|
|
2919
|
+
}
|
|
2595
2920
|
|
|
2596
|
-
|
|
2597
|
-
|
|
2921
|
+
// Calculate normalized axes position (0-1)
|
|
2922
|
+
if (axesBbox) {
|
|
2923
|
+
const axesWidth = axesBbox.x1 - axesBbox.x0;
|
|
2924
|
+
const axesHeight = axesBbox.y1 - axesBbox.y0;
|
|
2925
|
+
const elemCenterX = (bboxes[element].x0 + bboxes[element].x1) / 2;
|
|
2926
|
+
const elemCenterY = (bboxes[element].y0 + bboxes[element].y1) / 2;
|
|
2927
|
+
const normX = (elemCenterX - axesBbox.x0) / axesWidth;
|
|
2928
|
+
const normY = 1 - (elemCenterY - axesBbox.y0) / axesHeight; // Flip Y
|
|
2929
|
+
|
|
2930
|
+
// Update snap guide highlighting
|
|
2931
|
+
updateSnapHighlight(normX, normY);
|
|
2932
|
+
|
|
2933
|
+
// Show position indicator
|
|
2934
|
+
showElementPositionIndicator(element, normX, normY);
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
// Redraw overlay
|
|
2938
|
+
const overlay = img.parentElement?.querySelector('svg.panel-card-overlay');
|
|
2939
|
+
if (overlay) {
|
|
2940
|
+
const panelCache = panelBboxesCache[elementDragState.panelName];
|
|
2941
|
+
if (panelCache) {
|
|
2942
|
+
updatePanelOverlay(overlay, bboxes, panelCache.imgSize, rect.width, rect.height, element, element, img);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2598
2945
|
}
|
|
2599
2946
|
|
|
2600
|
-
|
|
2601
|
-
|
|
2947
|
+
// Stop element drag and save position
|
|
2948
|
+
function stopElementDrag() {
|
|
2949
|
+
if (!elementDragState) return;
|
|
2950
|
+
|
|
2951
|
+
const {element, panelName, elementType, axId, bboxes, axesBbox} = elementDragState;
|
|
2952
|
+
|
|
2953
|
+
// Calculate final normalized position
|
|
2954
|
+
let finalPosition = null;
|
|
2955
|
+
let snapName = null;
|
|
2956
|
+
|
|
2957
|
+
if (axesBbox && bboxes[element]) {
|
|
2958
|
+
const axesWidth = axesBbox.x1 - axesBbox.x0;
|
|
2959
|
+
const axesHeight = axesBbox.y1 - axesBbox.y0;
|
|
2960
|
+
const elemCenterX = (bboxes[element].x0 + bboxes[element].x1) / 2;
|
|
2961
|
+
const elemCenterY = (bboxes[element].y0 + bboxes[element].y1) / 2;
|
|
2962
|
+
const normX = (elemCenterX - axesBbox.x0) / axesWidth;
|
|
2963
|
+
const normY = 1 - (elemCenterY - axesBbox.y0) / axesHeight;
|
|
2964
|
+
|
|
2965
|
+
// Check for snap to named position
|
|
2966
|
+
snapName = findNearestSnapPosition(normX, normY);
|
|
2967
|
+
finalPosition = snapName ? SNAP_POSITIONS[snapName] : {x: normX, y: normY};
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// Hide snap guides
|
|
2971
|
+
hideSnapGuides();
|
|
2972
|
+
hideElementPositionIndicator();
|
|
2973
|
+
|
|
2974
|
+
// Save position to server
|
|
2975
|
+
if (finalPosition) {
|
|
2976
|
+
saveElementPosition(element, panelName, elementType, finalPosition, snapName);
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
document.removeEventListener('mousemove', onElementDrag);
|
|
2980
|
+
document.removeEventListener('mouseup', stopElementDrag);
|
|
2981
|
+
elementDragState = null;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// Find nearest snap position if within threshold
|
|
2985
|
+
function findNearestSnapPosition(normX, normY, threshold = 0.08) {
|
|
2986
|
+
let nearest = null;
|
|
2987
|
+
let minDist = Infinity;
|
|
2988
|
+
|
|
2989
|
+
for (const [name, pos] of Object.entries(SNAP_POSITIONS)) {
|
|
2990
|
+
const dist = Math.sqrt(Math.pow(normX - pos.x, 2) + Math.pow(normY - pos.y, 2));
|
|
2991
|
+
if (dist < threshold && dist < minDist) {
|
|
2992
|
+
minDist = dist;
|
|
2993
|
+
nearest = name;
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
return nearest;
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
// Show snap guide overlay on axes
|
|
3000
|
+
function showSnapGuides(img, axesBbox, bboxes) {
|
|
3001
|
+
if (!img || !axesBbox) return;
|
|
3002
|
+
|
|
3003
|
+
const container = img.parentElement;
|
|
3004
|
+
if (!container) return;
|
|
3005
|
+
|
|
3006
|
+
// Remove existing guides
|
|
3007
|
+
container.querySelectorAll('.snap-guide').forEach(el => el.remove());
|
|
3008
|
+
|
|
3009
|
+
const rect = img.getBoundingClientRect();
|
|
3010
|
+
const dims = getObjectFitContainDimensions(img);
|
|
3011
|
+
const imgSize = bboxes._meta?.imgSize || {width: dims.renderedWidth, height: dims.renderedHeight};
|
|
3012
|
+
|
|
3013
|
+
// Scale factors
|
|
3014
|
+
const scaleX = dims.renderedWidth / imgSize.width;
|
|
3015
|
+
const scaleY = dims.renderedHeight / imgSize.height;
|
|
3016
|
+
|
|
3017
|
+
// Create snap points
|
|
3018
|
+
for (const [name, pos] of Object.entries(SNAP_POSITIONS)) {
|
|
3019
|
+
const axesWidth = axesBbox.x1 - axesBbox.x0;
|
|
3020
|
+
const axesHeight = axesBbox.y1 - axesBbox.y0;
|
|
3021
|
+
|
|
3022
|
+
// Calculate pixel position
|
|
3023
|
+
const imgX = axesBbox.x0 + pos.x * axesWidth;
|
|
3024
|
+
const imgY = axesBbox.y0 + (1 - pos.y) * axesHeight;
|
|
3025
|
+
|
|
3026
|
+
const displayX = dims.offsetX + imgX * scaleX;
|
|
3027
|
+
const displayY = dims.offsetY + imgY * scaleY;
|
|
3028
|
+
|
|
3029
|
+
const guide = document.createElement('div');
|
|
3030
|
+
guide.className = 'snap-guide';
|
|
3031
|
+
guide.dataset.snapName = name;
|
|
3032
|
+
guide.style.cssText = `
|
|
3033
|
+
position: absolute;
|
|
3034
|
+
left: ${displayX - 6}px;
|
|
3035
|
+
top: ${displayY - 6}px;
|
|
3036
|
+
width: 12px;
|
|
3037
|
+
height: 12px;
|
|
3038
|
+
border: 2px dashed rgba(100, 149, 237, 0.6);
|
|
3039
|
+
border-radius: 50%;
|
|
3040
|
+
pointer-events: none;
|
|
3041
|
+
z-index: 50;
|
|
3042
|
+
transition: all 0.15s ease;
|
|
3043
|
+
`;
|
|
3044
|
+
container.appendChild(guide);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// Highlight snap position when near
|
|
3049
|
+
function updateSnapHighlight(normX, normY) {
|
|
3050
|
+
const threshold = 0.08;
|
|
3051
|
+
document.querySelectorAll('.snap-guide').forEach(guide => {
|
|
3052
|
+
const name = guide.dataset.snapName;
|
|
3053
|
+
const pos = SNAP_POSITIONS[name];
|
|
3054
|
+
const dist = Math.sqrt(Math.pow(normX - pos.x, 2) + Math.pow(normY - pos.y, 2));
|
|
3055
|
+
if (dist < threshold) {
|
|
3056
|
+
guide.style.borderColor = 'rgba(76, 175, 80, 0.9)';
|
|
3057
|
+
guide.style.backgroundColor = 'rgba(76, 175, 80, 0.3)';
|
|
3058
|
+
guide.style.transform = 'scale(1.5)';
|
|
3059
|
+
} else {
|
|
3060
|
+
guide.style.borderColor = 'rgba(100, 149, 237, 0.6)';
|
|
3061
|
+
guide.style.backgroundColor = 'transparent';
|
|
3062
|
+
guide.style.transform = 'scale(1)';
|
|
3063
|
+
}
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
// Hide snap guides
|
|
3068
|
+
function hideSnapGuides() {
|
|
3069
|
+
document.querySelectorAll('.snap-guide').forEach(el => el.remove());
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
// Show position indicator while dragging element
|
|
3073
|
+
function showElementPositionIndicator(element, normX, normY) {
|
|
3074
|
+
let indicator = document.getElementById('element-pos-indicator');
|
|
3075
|
+
if (!indicator) {
|
|
3076
|
+
indicator = document.createElement('div');
|
|
3077
|
+
indicator.id = 'element-pos-indicator';
|
|
3078
|
+
indicator.style.cssText = `
|
|
3079
|
+
position: fixed;
|
|
3080
|
+
bottom: 20px;
|
|
3081
|
+
right: 20px;
|
|
3082
|
+
background: rgba(0, 0, 0, 0.8);
|
|
3083
|
+
color: #4fc3f7;
|
|
3084
|
+
padding: 8px 12px;
|
|
3085
|
+
border-radius: 4px;
|
|
3086
|
+
font-family: monospace;
|
|
3087
|
+
font-size: 12px;
|
|
3088
|
+
z-index: 1000;
|
|
3089
|
+
`;
|
|
3090
|
+
document.body.appendChild(indicator);
|
|
3091
|
+
}
|
|
3092
|
+
const snapName = findNearestSnapPosition(normX, normY);
|
|
3093
|
+
if (snapName) {
|
|
3094
|
+
indicator.innerHTML = `Position: <b>${snapName}</b>`;
|
|
3095
|
+
indicator.style.color = '#4caf50';
|
|
3096
|
+
} else {
|
|
3097
|
+
indicator.innerHTML = `Position: (${normX.toFixed(2)}, ${normY.toFixed(2)})`;
|
|
3098
|
+
indicator.style.color = '#4fc3f7';
|
|
3099
|
+
}
|
|
3100
|
+
indicator.style.display = 'block';
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// Hide position indicator
|
|
3104
|
+
function hideElementPositionIndicator() {
|
|
3105
|
+
const indicator = document.getElementById('element-pos-indicator');
|
|
3106
|
+
if (indicator) indicator.style.display = 'none';
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
// Save element position to server
|
|
3110
|
+
async function saveElementPosition(element, panelName, elementType, position, snapName) {
|
|
3111
|
+
try {
|
|
3112
|
+
const response = await fetch('/save_element_position', {
|
|
3113
|
+
method: 'POST',
|
|
3114
|
+
headers: {'Content-Type': 'application/json'},
|
|
3115
|
+
body: JSON.stringify({
|
|
3116
|
+
element: element,
|
|
3117
|
+
panel: panelName,
|
|
3118
|
+
element_type: elementType,
|
|
3119
|
+
position: position,
|
|
3120
|
+
snap_name: snapName,
|
|
3121
|
+
}),
|
|
3122
|
+
});
|
|
3123
|
+
const data = await response.json();
|
|
3124
|
+
if (data.success) {
|
|
3125
|
+
setStatus(`Saved ${elementType} position: ${snapName || `(${position.x.toFixed(2)}, ${position.y.toFixed(2)})`}`, false);
|
|
3126
|
+
} else {
|
|
3127
|
+
setStatus(`Failed to save position: ${data.error}`, true);
|
|
3128
|
+
}
|
|
3129
|
+
} catch (err) {
|
|
3130
|
+
console.error('Error saving element position:', err);
|
|
3131
|
+
setStatus('Error saving position', true);
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
// Initialize drag handler for a panel item
|
|
3136
|
+
function initPanelDrag(item, panelName) {
|
|
3137
|
+
const dragHandle = item.querySelector('.panel-drag-handle');
|
|
3138
|
+
|
|
3139
|
+
// Drag from handle (always works)
|
|
3140
|
+
if (dragHandle) {
|
|
3141
|
+
dragHandle.addEventListener('mousedown', (e) => {
|
|
3142
|
+
e.preventDefault();
|
|
3143
|
+
e.stopPropagation();
|
|
3144
|
+
startPanelDrag(e, item, panelName);
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
// Also allow dragging from panel label
|
|
3149
|
+
const label = item.querySelector('.panel-canvas-label');
|
|
3150
|
+
if (label) {
|
|
3151
|
+
label.style.cursor = 'move';
|
|
3152
|
+
label.addEventListener('mousedown', (e) => {
|
|
3153
|
+
e.preventDefault();
|
|
3154
|
+
e.stopPropagation();
|
|
3155
|
+
startPanelDrag(e, item, panelName);
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// Allow dragging from anywhere on the panel (except interactive elements)
|
|
3160
|
+
// This enables intuitive drag behavior while preserving element selection
|
|
3161
|
+
item.addEventListener('mousedown', (e) => {
|
|
3162
|
+
// Skip if clicking on interactive elements (legends, text paths, etc.)
|
|
3163
|
+
if (isInteractiveElement(e.target)) return;
|
|
3164
|
+
|
|
3165
|
+
// Skip if clicking on drag handle or label (already handled above)
|
|
3166
|
+
if (e.target.closest('.panel-drag-handle')) return;
|
|
3167
|
+
if (e.target.closest('.panel-canvas-label')) return;
|
|
3168
|
+
if (e.target.closest('.panel-position-indicator')) return;
|
|
3169
|
+
|
|
3170
|
+
// Start drag from anywhere else on the panel
|
|
3171
|
+
e.preventDefault();
|
|
3172
|
+
startPanelDrag(e, item, panelName);
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
// Set cursor to indicate draggability
|
|
3176
|
+
item.style.cursor = 'grab';
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
function startPanelDrag(e, item, name) {
|
|
3180
|
+
e.preventDefault();
|
|
3181
|
+
|
|
3182
|
+
// Handle selection based on Ctrl key
|
|
3183
|
+
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
|
3184
|
+
const wasAlreadySelected = item.classList.contains('active');
|
|
3185
|
+
|
|
3186
|
+
if (isCtrlPressed) {
|
|
3187
|
+
// Ctrl+Click: toggle this panel's selection
|
|
3188
|
+
item.classList.toggle('active');
|
|
3189
|
+
} else if (!wasAlreadySelected) {
|
|
3190
|
+
// Regular click on unselected panel: select only this one
|
|
3191
|
+
deselectAllPanels();
|
|
3192
|
+
item.classList.add('active');
|
|
3193
|
+
}
|
|
3194
|
+
// If clicking on already-selected panel without Ctrl:
|
|
3195
|
+
// Don't change selection yet - could be start of multi-panel drag
|
|
3196
|
+
// Selection will be finalized in stopPanelDrag based on hasMoved
|
|
3197
|
+
|
|
3198
|
+
// Collect all selected panels for group dragging
|
|
3199
|
+
const selectedPanels = Array.from(document.querySelectorAll('.panel-canvas-item.active'));
|
|
3200
|
+
if (selectedPanels.length === 0) {
|
|
3201
|
+
// If somehow nothing selected, select the clicked item
|
|
3202
|
+
item.classList.add('active');
|
|
3203
|
+
selectedPanels.push(item);
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
// Store drag state for all selected panels
|
|
3207
|
+
draggedPanel = {
|
|
3208
|
+
item,
|
|
3209
|
+
name,
|
|
3210
|
+
hasMoved: false, // Track if actual drag occurred
|
|
3211
|
+
wasAlreadySelected, // Track initial selection state for click handling
|
|
3212
|
+
isCtrlPressed, // Track if Ctrl was pressed
|
|
3213
|
+
selectedPanels: selectedPanels.map(p => ({
|
|
3214
|
+
item: p,
|
|
3215
|
+
name: p.dataset.panelName,
|
|
3216
|
+
startLeft: parseFloat(p.style.left) || 0,
|
|
3217
|
+
startTop: parseFloat(p.style.top) || 0
|
|
3218
|
+
}))
|
|
3219
|
+
};
|
|
3220
|
+
dragOffset.x = e.clientX;
|
|
3221
|
+
dragOffset.y = e.clientY;
|
|
3222
|
+
|
|
3223
|
+
selectedPanels.forEach(p => {
|
|
3224
|
+
p.classList.add('dragging');
|
|
3225
|
+
p.style.cursor = 'grabbing';
|
|
3226
|
+
});
|
|
3227
|
+
|
|
3228
|
+
// Show position indicator for primary panel
|
|
3229
|
+
updatePositionIndicator(name, item.offsetLeft, item.offsetTop);
|
|
3230
|
+
|
|
3231
|
+
document.addEventListener('mousemove', onPanelDrag);
|
|
3232
|
+
document.addEventListener('mouseup', stopPanelDrag);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
function onPanelDrag(e) {
|
|
3236
|
+
if (!draggedPanel || !draggedPanel.selectedPanels) return;
|
|
2602
3237
|
const canvasEl = document.getElementById('panel-canvas');
|
|
2603
|
-
const rect = canvasEl.getBoundingClientRect();
|
|
2604
3238
|
|
|
2605
|
-
|
|
2606
|
-
let
|
|
3239
|
+
// Calculate delta from drag start
|
|
3240
|
+
let deltaX = e.clientX - dragOffset.x;
|
|
3241
|
+
let deltaY = e.clientY - dragOffset.y;
|
|
2607
3242
|
|
|
2608
|
-
//
|
|
2609
|
-
|
|
2610
|
-
|
|
3243
|
+
// Mark as moved if we've actually dragged (threshold: 3px)
|
|
3244
|
+
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
|
|
3245
|
+
draggedPanel.hasMoved = true;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
// Snap to grid (optional: 5mm grid)
|
|
3249
|
+
const gridSnap = 5 * canvasScale; // 5mm in pixels
|
|
3250
|
+
if (e.shiftKey) {
|
|
3251
|
+
deltaX = Math.round(deltaX / gridSnap) * gridSnap;
|
|
3252
|
+
deltaY = Math.round(deltaY / gridSnap) * gridSnap;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
// Move all selected panels by the same delta
|
|
3256
|
+
for (const panelInfo of draggedPanel.selectedPanels) {
|
|
3257
|
+
let newX = panelInfo.startLeft + deltaX;
|
|
3258
|
+
let newY = panelInfo.startTop + deltaY;
|
|
3259
|
+
|
|
3260
|
+
// Constrain to canvas bounds (allow slight negative for edge alignment)
|
|
3261
|
+
newX = Math.max(-5, Math.min(newX, canvasEl.offsetWidth - panelInfo.item.offsetWidth + 5));
|
|
3262
|
+
newY = Math.max(-5, newY);
|
|
3263
|
+
|
|
3264
|
+
panelInfo.item.style.left = newX + 'px';
|
|
3265
|
+
panelInfo.item.style.top = newY + 'px';
|
|
3266
|
+
|
|
3267
|
+
// Update pixel positions
|
|
3268
|
+
if (panelPositions[panelInfo.name]) {
|
|
3269
|
+
panelPositions[panelInfo.name].x = newX;
|
|
3270
|
+
panelPositions[panelInfo.name].y = newY;
|
|
3271
|
+
}
|
|
2611
3272
|
|
|
2612
|
-
|
|
2613
|
-
|
|
3273
|
+
// Update mm positions
|
|
3274
|
+
if (panelLayoutMm[panelInfo.name]) {
|
|
3275
|
+
panelLayoutMm[panelInfo.name].x_mm = newX / canvasScale;
|
|
3276
|
+
panelLayoutMm[panelInfo.name].y_mm = newY / canvasScale;
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
2614
3279
|
|
|
2615
|
-
|
|
2616
|
-
|
|
3280
|
+
// Show position indicator for primary panel
|
|
3281
|
+
const primaryNewX = draggedPanel.selectedPanels[0].startLeft + deltaX;
|
|
3282
|
+
const primaryNewY = draggedPanel.selectedPanels[0].startTop + deltaY;
|
|
3283
|
+
updatePositionIndicator(draggedPanel.name, primaryNewX, primaryNewY);
|
|
3284
|
+
|
|
3285
|
+
// Mark layout as modified
|
|
3286
|
+
layoutModified = true;
|
|
2617
3287
|
}
|
|
2618
3288
|
|
|
2619
|
-
function
|
|
3289
|
+
function stopPanelDrag() {
|
|
2620
3290
|
if (draggedPanel) {
|
|
2621
|
-
|
|
3291
|
+
// Handle click (no movement) on already-selected panel without Ctrl:
|
|
3292
|
+
// Finalize selection to only the clicked panel
|
|
3293
|
+
if (!draggedPanel.hasMoved && draggedPanel.wasAlreadySelected && !draggedPanel.isCtrlPressed) {
|
|
3294
|
+
// This was a simple click on an already-selected panel
|
|
3295
|
+
// Deselect all others, keep only the clicked panel selected
|
|
3296
|
+
deselectAllPanels();
|
|
3297
|
+
draggedPanel.item.classList.add('active');
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// Reset cursor for all selected panels
|
|
3301
|
+
if (draggedPanel.selectedPanels) {
|
|
3302
|
+
draggedPanel.selectedPanels.forEach(p => {
|
|
3303
|
+
p.item.classList.remove('dragging');
|
|
3304
|
+
p.item.style.cursor = 'grab';
|
|
3305
|
+
});
|
|
3306
|
+
} else {
|
|
3307
|
+
draggedPanel.item.classList.remove('dragging');
|
|
3308
|
+
draggedPanel.item.style.cursor = 'grab';
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// Update canvas size if panel moved outside
|
|
3312
|
+
updateCanvasSize();
|
|
3313
|
+
|
|
3314
|
+
// Hide position indicator after a delay
|
|
3315
|
+
const name = draggedPanel.name;
|
|
3316
|
+
setTimeout(() => {
|
|
3317
|
+
const indicator = document.getElementById(`pos-${name}`);
|
|
3318
|
+
if (indicator) indicator.style.opacity = '0';
|
|
3319
|
+
}, 1500);
|
|
3320
|
+
|
|
3321
|
+
// Auto-save layout
|
|
3322
|
+
if (layoutModified) {
|
|
3323
|
+
autoSaveLayout();
|
|
3324
|
+
}
|
|
3325
|
+
|
|
2622
3326
|
draggedPanel = null;
|
|
2623
3327
|
}
|
|
2624
|
-
document.removeEventListener('mousemove',
|
|
2625
|
-
document.removeEventListener('mouseup',
|
|
3328
|
+
document.removeEventListener('mousemove', onPanelDrag);
|
|
3329
|
+
document.removeEventListener('mouseup', stopPanelDrag);
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
// Update position indicator showing mm coordinates
|
|
3333
|
+
function updatePositionIndicator(panelName, x, y) {
|
|
3334
|
+
const indicator = document.getElementById(`pos-${panelName}`);
|
|
3335
|
+
if (!indicator) return;
|
|
3336
|
+
|
|
3337
|
+
const x_mm = (x / canvasScale).toFixed(1);
|
|
3338
|
+
const y_mm = (y / canvasScale).toFixed(1);
|
|
3339
|
+
indicator.textContent = `${x_mm}, ${y_mm} mm`;
|
|
3340
|
+
indicator.style.opacity = '1';
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
// Update canvas size to fit all panels after drag
|
|
3344
|
+
function updateCanvasSize() {
|
|
3345
|
+
const canvasEl = document.getElementById('panel-canvas');
|
|
3346
|
+
if (!canvasEl) return;
|
|
3347
|
+
|
|
3348
|
+
const maxY = Math.max(...Object.values(panelPositions).map(p => p.y + p.height)) + 20;
|
|
3349
|
+
const maxX = Math.max(...Object.values(panelPositions).map(p => p.x + p.width)) + 20;
|
|
3350
|
+
canvasEl.style.minHeight = Math.max(400, maxY) + 'px';
|
|
3351
|
+
canvasEl.style.minWidth = Math.max(700, maxX) + 'px';
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// Auto-save layout to server
|
|
3355
|
+
async function autoSaveLayout() {
|
|
3356
|
+
if (!layoutModified) return;
|
|
3357
|
+
|
|
3358
|
+
try {
|
|
3359
|
+
const resp = await fetch('/save_layout', {
|
|
3360
|
+
method: 'POST',
|
|
3361
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3362
|
+
body: JSON.stringify({ layout: panelLayoutMm })
|
|
3363
|
+
});
|
|
3364
|
+
const data = await resp.json();
|
|
3365
|
+
|
|
3366
|
+
if (data.success) {
|
|
3367
|
+
layoutModified = false;
|
|
3368
|
+
setStatus('Layout saved', false);
|
|
3369
|
+
console.log('Layout auto-saved:', panelLayoutMm);
|
|
3370
|
+
} else {
|
|
3371
|
+
console.error('Layout save failed:', data.error);
|
|
3372
|
+
setStatus('Layout save failed: ' + data.error, true);
|
|
3373
|
+
}
|
|
3374
|
+
} catch (e) {
|
|
3375
|
+
console.error('Error saving layout:', e);
|
|
3376
|
+
setStatus('Error saving layout', true);
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
// Manual save layout button handler
|
|
3381
|
+
function saveLayoutManually() {
|
|
3382
|
+
layoutModified = true; // Force save
|
|
3383
|
+
autoSaveLayout();
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
// Legacy drag functions (kept for backward compatibility with canvas mode)
|
|
3387
|
+
function startDrag(e, item, name) {
|
|
3388
|
+
startPanelDrag(e, item, name);
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
function onDrag(e) {
|
|
3392
|
+
onPanelDrag(e);
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
function stopDrag() {
|
|
3396
|
+
stopPanelDrag();
|
|
2626
3397
|
}
|
|
2627
3398
|
|
|
2628
3399
|
let resizingPanel = null;
|
|
@@ -2905,6 +3676,18 @@ async function saveManual() {
|
|
|
2905
3676
|
const data = await resp.json();
|
|
2906
3677
|
if (data.status === 'saved') {
|
|
2907
3678
|
setStatus('Saved: ' + data.path.split('/').pop(), false);
|
|
3679
|
+
|
|
3680
|
+
// Also export to bundle (png and svg)
|
|
3681
|
+
try {
|
|
3682
|
+
await fetch('/export', {
|
|
3683
|
+
method: 'POST',
|
|
3684
|
+
headers: {'Content-Type': 'application/json'},
|
|
3685
|
+
body: JSON.stringify({formats: ['png', 'svg']})
|
|
3686
|
+
});
|
|
3687
|
+
setStatus('Saved and exported to bundle', false);
|
|
3688
|
+
} catch (exportErr) {
|
|
3689
|
+
console.warn('Export failed:', exportErr);
|
|
3690
|
+
}
|
|
2908
3691
|
} else {
|
|
2909
3692
|
setStatus('Error: ' + data.message, true);
|
|
2910
3693
|
}
|
|
@@ -2919,6 +3702,47 @@ function resetOverrides() {
|
|
|
2919
3702
|
}
|
|
2920
3703
|
}
|
|
2921
3704
|
|
|
3705
|
+
// Download menu toggle
|
|
3706
|
+
function toggleDownloadMenu() {
|
|
3707
|
+
const menu = document.getElementById('download-menu');
|
|
3708
|
+
if (menu.style.display === 'none') {
|
|
3709
|
+
menu.style.display = 'block';
|
|
3710
|
+
// Close when clicking outside
|
|
3711
|
+
setTimeout(() => {
|
|
3712
|
+
document.addEventListener('click', closeDownloadMenuOnClickOutside);
|
|
3713
|
+
}, 10);
|
|
3714
|
+
} else {
|
|
3715
|
+
menu.style.display = 'none';
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
function closeDownloadMenuOnClickOutside(e) {
|
|
3720
|
+
const menu = document.getElementById('download-menu');
|
|
3721
|
+
const btn = document.getElementById('download-btn');
|
|
3722
|
+
if (!menu.contains(e.target) && !btn.contains(e.target)) {
|
|
3723
|
+
menu.style.display = 'none';
|
|
3724
|
+
document.removeEventListener('click', closeDownloadMenuOnClickOutside);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
// Export figure to bundle and trigger download
|
|
3729
|
+
async function exportAndDownload(format) {
|
|
3730
|
+
setStatus(`Exporting ${format.toUpperCase()}...`, false);
|
|
3731
|
+
try {
|
|
3732
|
+
// First export to bundle
|
|
3733
|
+
await fetch('/export', {
|
|
3734
|
+
method: 'POST',
|
|
3735
|
+
headers: {'Content-Type': 'application/json'},
|
|
3736
|
+
body: JSON.stringify({formats: [format]})
|
|
3737
|
+
});
|
|
3738
|
+
// Then trigger download
|
|
3739
|
+
window.location.href = `/download/${format}`;
|
|
3740
|
+
setStatus(`Downloaded ${format.toUpperCase()}`, false);
|
|
3741
|
+
} catch (e) {
|
|
3742
|
+
setStatus('Export error: ' + e.message, true);
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
|
|
2922
3746
|
function addAnnotation() {
|
|
2923
3747
|
const text = document.getElementById('annot-text').value;
|
|
2924
3748
|
if (!text) return;
|
|
@@ -3057,14 +3881,24 @@ function renderGroupStats(group) {
|
|
|
3057
3881
|
|
|
3058
3882
|
function setStatus(msg, isError = false) {
|
|
3059
3883
|
const el = document.getElementById('status');
|
|
3060
|
-
const
|
|
3884
|
+
const globalOverlay = document.getElementById('global-loading-overlay');
|
|
3885
|
+
const localOverlay = document.getElementById('loading-overlay');
|
|
3061
3886
|
|
|
3062
3887
|
// Show/hide spinner for loading states
|
|
3063
3888
|
if (msg === 'Updating...' || msg === 'Loading preview...') {
|
|
3064
|
-
|
|
3889
|
+
// Show global overlay (visible for both single and multi-panel views)
|
|
3890
|
+
if (globalOverlay) {
|
|
3891
|
+
globalOverlay.style.display = 'flex';
|
|
3892
|
+
const loadingText = globalOverlay.querySelector('.loading-text');
|
|
3893
|
+
if (loadingText) loadingText.textContent = msg;
|
|
3894
|
+
}
|
|
3895
|
+
// Also show local overlay if visible
|
|
3896
|
+
if (localOverlay) localOverlay.style.display = 'flex';
|
|
3065
3897
|
el.textContent = ''; // Clear status text during loading
|
|
3066
3898
|
} else {
|
|
3067
|
-
|
|
3899
|
+
// Hide both overlays
|
|
3900
|
+
if (globalOverlay) globalOverlay.style.display = 'none';
|
|
3901
|
+
if (localOverlay) localOverlay.style.display = 'none';
|
|
3068
3902
|
el.textContent = msg;
|
|
3069
3903
|
}
|
|
3070
3904
|
el.classList.toggle('error', isError);
|
|
@@ -3106,11 +3940,974 @@ document.querySelectorAll('input[type="color"]').forEach(el => {
|
|
|
3106
3940
|
});
|
|
3107
3941
|
});
|
|
3108
3942
|
|
|
3109
|
-
//
|
|
3943
|
+
// =============================================================================
|
|
3944
|
+
// Keyboard Shortcuts (matching SciTeX Cloud vis app)
|
|
3945
|
+
// =============================================================================
|
|
3946
|
+
let shortcutMode = null; // For multi-key shortcuts like Alt+A → L
|
|
3947
|
+
|
|
3110
3948
|
document.addEventListener('keydown', (e) => {
|
|
3111
|
-
|
|
3949
|
+
const key = e.key.toLowerCase();
|
|
3950
|
+
const isCtrl = e.ctrlKey || e.metaKey;
|
|
3951
|
+
const isShift = e.shiftKey;
|
|
3952
|
+
const isAlt = e.altKey;
|
|
3953
|
+
|
|
3954
|
+
// Don't capture shortcuts when typing in inputs
|
|
3955
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// =========================================================================
|
|
3960
|
+
// Multi-key shortcut mode (Alt+A → alignment, Alt+Shift+A → axis alignment)
|
|
3961
|
+
// =========================================================================
|
|
3962
|
+
if (shortcutMode === 'align') {
|
|
3112
3963
|
e.preventDefault();
|
|
3113
|
-
|
|
3964
|
+
handleAlignShortcut(key, isShift);
|
|
3965
|
+
shortcutMode = null;
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
|
|
3969
|
+
if (shortcutMode === 'alignByAxis') {
|
|
3970
|
+
e.preventDefault();
|
|
3971
|
+
handleAlignByAxisShortcut(key);
|
|
3972
|
+
shortcutMode = null;
|
|
3973
|
+
return;
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
// =========================================================================
|
|
3977
|
+
// Basic Operations
|
|
3978
|
+
// =========================================================================
|
|
3979
|
+
|
|
3980
|
+
// Ctrl+S: Save
|
|
3981
|
+
if (isCtrl && key === 's') {
|
|
3982
|
+
e.preventDefault();
|
|
3983
|
+
saveManual();
|
|
3984
|
+
return;
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
// Ctrl+Z: Undo
|
|
3988
|
+
if (isCtrl && !isShift && key === 'z') {
|
|
3989
|
+
e.preventDefault();
|
|
3990
|
+
undoLastChange();
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
// Ctrl+Y or Ctrl+Shift+Z: Redo
|
|
3995
|
+
if ((isCtrl && key === 'y') || (isCtrl && isShift && key === 'z')) {
|
|
3996
|
+
e.preventDefault();
|
|
3997
|
+
redoLastChange();
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
// Delete: Remove selected element override
|
|
4002
|
+
if (key === 'delete' || key === 'backspace') {
|
|
4003
|
+
if (selectedElement && !isCtrl) {
|
|
4004
|
+
e.preventDefault();
|
|
4005
|
+
deleteSelectedOverride();
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
// =========================================================================
|
|
4011
|
+
// Panel/Element Movement (Arrow keys)
|
|
4012
|
+
// =========================================================================
|
|
4013
|
+
|
|
4014
|
+
// Arrow keys: Move selected panel by 1mm (or 5mm with Shift)
|
|
4015
|
+
if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(key)) {
|
|
4016
|
+
e.preventDefault();
|
|
4017
|
+
const amount = isShift ? 5 : 1; // 5mm or 1mm
|
|
4018
|
+
moveSelectedPanel(key.replace('arrow', ''), amount);
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
// =========================================================================
|
|
4023
|
+
// View Controls
|
|
4024
|
+
// =========================================================================
|
|
4025
|
+
|
|
4026
|
+
// + or =: Zoom in
|
|
4027
|
+
if ((key === '+' || key === '=') && !isCtrl) {
|
|
4028
|
+
e.preventDefault();
|
|
4029
|
+
zoomCanvas(1.1);
|
|
4030
|
+
return;
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
// -: Zoom out
|
|
4034
|
+
if (key === '-' && !isCtrl) {
|
|
4035
|
+
e.preventDefault();
|
|
4036
|
+
zoomCanvas(0.9);
|
|
4037
|
+
return;
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
// 0: Fit to window
|
|
4041
|
+
if (key === '0' && !isCtrl) {
|
|
4042
|
+
e.preventDefault();
|
|
4043
|
+
fitCanvasToWindow();
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
// Ctrl++ : Increase canvas size
|
|
4048
|
+
if (isCtrl && (key === '+' || key === '=')) {
|
|
4049
|
+
e.preventDefault();
|
|
4050
|
+
resizeCanvas(1.1);
|
|
4051
|
+
return;
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
// Ctrl+- : Decrease canvas size
|
|
4055
|
+
if (isCtrl && key === '-') {
|
|
4056
|
+
e.preventDefault();
|
|
4057
|
+
resizeCanvas(0.9);
|
|
4058
|
+
return;
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
// =========================================================================
|
|
4062
|
+
// Alignment Modes (Alt+A → basic, Alt+Shift+A → by axis)
|
|
4063
|
+
// =========================================================================
|
|
4064
|
+
if (isAlt && isShift && key === 'a') {
|
|
4065
|
+
// Alt+Shift+A: Align by Axis (scientific alignment based on plot axes)
|
|
4066
|
+
e.preventDefault();
|
|
4067
|
+
shortcutMode = 'alignByAxis';
|
|
4068
|
+
setStatus('Align by Axis: L=Y-Axis(left) R=Right T=Top B=X-Axis(bottom) C=Center-H M=Center-V S=Stack', false);
|
|
4069
|
+
setTimeout(() => {
|
|
4070
|
+
if (shortcutMode === 'alignByAxis') {
|
|
4071
|
+
shortcutMode = null;
|
|
4072
|
+
setStatus('Ready', false);
|
|
4073
|
+
}
|
|
4074
|
+
}, 3000);
|
|
4075
|
+
return;
|
|
4076
|
+
}
|
|
4077
|
+
if (isAlt && !isShift && key === 'a') {
|
|
4078
|
+
// Alt+A: Basic alignment (by bounding box)
|
|
4079
|
+
e.preventDefault();
|
|
4080
|
+
shortcutMode = 'align';
|
|
4081
|
+
setStatus('Alignment mode: L=Left R=Right T=Top B=Bottom C=Center H=DistH V=DistV', false);
|
|
4082
|
+
setTimeout(() => {
|
|
4083
|
+
if (shortcutMode === 'align') {
|
|
4084
|
+
shortcutMode = null;
|
|
4085
|
+
setStatus('Ready', false);
|
|
4086
|
+
}
|
|
4087
|
+
}, 3000);
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
// =========================================================================
|
|
4092
|
+
// Arrange (Alt+F, Alt+B)
|
|
4093
|
+
// =========================================================================
|
|
4094
|
+
if (isAlt && key === 'f') {
|
|
4095
|
+
e.preventDefault();
|
|
4096
|
+
bringPanelToFront();
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
4099
|
+
if (isAlt && key === 'b') {
|
|
4100
|
+
e.preventDefault();
|
|
4101
|
+
sendPanelToBack();
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
// =========================================================================
|
|
4106
|
+
// Escape: Deselect/Cancel mode
|
|
4107
|
+
// =========================================================================
|
|
4108
|
+
if (key === 'escape') {
|
|
4109
|
+
e.preventDefault();
|
|
4110
|
+
shortcutMode = null;
|
|
4111
|
+
deselectAllPanels();
|
|
4112
|
+
setStatus('Ready', false);
|
|
4113
|
+
return;
|
|
4114
|
+
}
|
|
4115
|
+
|
|
4116
|
+
// =========================================================================
|
|
4117
|
+
// G: Toggle grid visibility
|
|
4118
|
+
// =========================================================================
|
|
4119
|
+
if (key === 'g' && !isCtrl && !isAlt) {
|
|
4120
|
+
e.preventDefault();
|
|
4121
|
+
toggleGridVisibility();
|
|
4122
|
+
return;
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
// =========================================================================
|
|
4126
|
+
// Ctrl+A: Select all panels
|
|
4127
|
+
// =========================================================================
|
|
4128
|
+
if (isCtrl && key === 'a') {
|
|
4129
|
+
e.preventDefault();
|
|
4130
|
+
selectAllPanels();
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
// =========================================================================
|
|
4135
|
+
// Help (? or F1)
|
|
4136
|
+
// =========================================================================
|
|
4137
|
+
if (key === '?' || key === 'f1') {
|
|
4138
|
+
e.preventDefault();
|
|
4139
|
+
showShortcutHelp();
|
|
4140
|
+
return;
|
|
4141
|
+
}
|
|
4142
|
+
});
|
|
4143
|
+
|
|
4144
|
+
// Handle alignment sub-shortcuts (basic bounding box alignment)
|
|
4145
|
+
function handleAlignShortcut(key, isShift) {
|
|
4146
|
+
const panels = document.querySelectorAll('.panel-canvas-item');
|
|
4147
|
+
if (panels.length < 2) {
|
|
4148
|
+
setStatus('Need multiple panels for alignment', true);
|
|
4149
|
+
return;
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
switch(key) {
|
|
4153
|
+
case 'l': alignPanels('left'); break;
|
|
4154
|
+
case 'r': alignPanels('right'); break;
|
|
4155
|
+
case 't': alignPanels('top'); break;
|
|
4156
|
+
case 'b': alignPanels('bottom'); break;
|
|
4157
|
+
case 'c': alignPanels('center-h'); break;
|
|
4158
|
+
case 'm': alignPanels('center-v'); break;
|
|
4159
|
+
case 'h': distributePanels('horizontal'); break;
|
|
4160
|
+
case 'v': distributePanels('vertical'); break;
|
|
4161
|
+
default:
|
|
4162
|
+
setStatus('Unknown alignment key: ' + key, true);
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
// Handle axis-based alignment sub-shortcuts (scientific plot alignment)
|
|
4167
|
+
function handleAlignByAxisShortcut(key) {
|
|
4168
|
+
const panels = document.querySelectorAll('.panel-canvas-item');
|
|
4169
|
+
if (panels.length < 2) {
|
|
4170
|
+
setStatus('Need multiple panels for axis alignment', true);
|
|
4171
|
+
return;
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
const dirNames = {
|
|
4175
|
+
'l': 'Y-axis (left edge)',
|
|
4176
|
+
'r': 'Right edge',
|
|
4177
|
+
't': 'Top edge',
|
|
4178
|
+
'b': 'X-axis (bottom edge)',
|
|
4179
|
+
'c': 'Center horizontal',
|
|
4180
|
+
'm': 'Center vertical',
|
|
4181
|
+
's': 'Stacked vertically'
|
|
4182
|
+
};
|
|
4183
|
+
|
|
4184
|
+
switch(key) {
|
|
4185
|
+
case 'l': alignPanelsByAxis('left'); break; // Y-axis left
|
|
4186
|
+
case 'r': alignPanelsByAxis('right'); break; // Right edge
|
|
4187
|
+
case 't': alignPanelsByAxis('top'); break; // Top edge
|
|
4188
|
+
case 'b': alignPanelsByAxis('bottom'); break; // X-axis bottom
|
|
4189
|
+
case 'c': alignPanelsByAxis('center-h'); break; // Horizontal center
|
|
4190
|
+
case 'm': alignPanelsByAxis('center-v'); break; // Vertical center
|
|
4191
|
+
case 's': stackPanelsVertically(); break; // Stack with Y-axis alignment
|
|
4192
|
+
default:
|
|
4193
|
+
setStatus('Unknown axis key: ' + key + '. Use L/R/T/B/C/M/S', true);
|
|
4194
|
+
return;
|
|
4195
|
+
}
|
|
4196
|
+
if (dirNames[key]) {
|
|
4197
|
+
setStatus(`Aligned by axis: ${dirNames[key]}`, false);
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
// Get axes bounding box from panel's cached bboxes
|
|
4202
|
+
// Returns {x0, y0, x1, y1} in image pixels, or null if not found
|
|
4203
|
+
function getAxesBboxForPanel(panelName) {
|
|
4204
|
+
const cache = panelBboxesCache[panelName];
|
|
4205
|
+
if (!cache || !cache.bboxes) return null;
|
|
4206
|
+
|
|
4207
|
+
// Look for ax_00_panel, ax_01_panel, etc.
|
|
4208
|
+
const bboxes = cache.bboxes;
|
|
4209
|
+
for (const key of Object.keys(bboxes)) {
|
|
4210
|
+
if (key.endsWith('_panel') && key.startsWith('ax_')) {
|
|
4211
|
+
const bbox = bboxes[key];
|
|
4212
|
+
if (bbox && bbox.x0 !== undefined) {
|
|
4213
|
+
return {
|
|
4214
|
+
x0: bbox.x0,
|
|
4215
|
+
y0: bbox.y0,
|
|
4216
|
+
x1: bbox.x1,
|
|
4217
|
+
y1: bbox.y1,
|
|
4218
|
+
key: key
|
|
4219
|
+
};
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
// Fallback: check _meta.axes_bbox_px for single-axes plots
|
|
4225
|
+
if (bboxes._meta && bboxes._meta.axes_bbox_px) {
|
|
4226
|
+
const axBbox = bboxes._meta.axes_bbox_px;
|
|
4227
|
+
return {
|
|
4228
|
+
x0: axBbox.x0 || axBbox.x,
|
|
4229
|
+
y0: axBbox.y0 || axBbox.y,
|
|
4230
|
+
x1: axBbox.x1 || (axBbox.x + axBbox.width),
|
|
4231
|
+
y1: axBbox.y1 || (axBbox.y + axBbox.height),
|
|
4232
|
+
key: '_meta.axes_bbox_px'
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
return null;
|
|
4237
|
+
}
|
|
4238
|
+
|
|
4239
|
+
// Calculate panel offset to align by axis edge
|
|
4240
|
+
// Returns the axis edge position in canvas pixels relative to panel's top-left
|
|
4241
|
+
function getAxisEdgeOffset(panel, axesBbox, edge, imgSize) {
|
|
4242
|
+
if (!axesBbox || !imgSize) return 0;
|
|
4243
|
+
|
|
4244
|
+
// Scale factor from image pixels to displayed panel pixels
|
|
4245
|
+
const panelEl = panel;
|
|
4246
|
+
const displayWidth = panelEl.offsetWidth;
|
|
4247
|
+
const displayHeight = panelEl.offsetHeight;
|
|
4248
|
+
const scaleX = displayWidth / imgSize.width;
|
|
4249
|
+
const scaleY = displayHeight / imgSize.height;
|
|
4250
|
+
|
|
4251
|
+
switch(edge) {
|
|
4252
|
+
case 'left':
|
|
4253
|
+
// Y-axis left edge
|
|
4254
|
+
return axesBbox.x0 * scaleX;
|
|
4255
|
+
case 'right':
|
|
4256
|
+
// Right edge of axes
|
|
4257
|
+
return axesBbox.x1 * scaleX;
|
|
4258
|
+
case 'top':
|
|
4259
|
+
// Top edge of axes
|
|
4260
|
+
return axesBbox.y0 * scaleY;
|
|
4261
|
+
case 'bottom':
|
|
4262
|
+
// X-axis bottom edge
|
|
4263
|
+
return axesBbox.y1 * scaleY;
|
|
4264
|
+
case 'center-h':
|
|
4265
|
+
// Horizontal center of axes
|
|
4266
|
+
return ((axesBbox.x0 + axesBbox.x1) / 2) * scaleX;
|
|
4267
|
+
case 'center-v':
|
|
4268
|
+
// Vertical center of axes
|
|
4269
|
+
return ((axesBbox.y0 + axesBbox.y1) / 2) * scaleY;
|
|
4270
|
+
default:
|
|
4271
|
+
return 0;
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
// Align panels by axis edges (scientific alignment for plots)
|
|
4276
|
+
function alignPanelsByAxis(edge) {
|
|
4277
|
+
const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
|
|
4278
|
+
if (panels.length < 2) {
|
|
4279
|
+
setStatus('Need multiple panels for axis alignment', true);
|
|
4280
|
+
return;
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
// Collect panel info with axes bboxes
|
|
4284
|
+
const panelInfos = [];
|
|
4285
|
+
for (const panel of panels) {
|
|
4286
|
+
const panelName = panel.dataset.panelName;
|
|
4287
|
+
const cache = panelBboxesCache[panelName];
|
|
4288
|
+
const axesBbox = getAxesBboxForPanel(panelName);
|
|
4289
|
+
const imgSize = cache ? cache.imgSize : null;
|
|
4290
|
+
|
|
4291
|
+
if (!axesBbox || !imgSize) {
|
|
4292
|
+
console.warn(`Panel ${panelName} has no axes bbox data`);
|
|
4293
|
+
continue;
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
panelInfos.push({
|
|
4297
|
+
el: panel,
|
|
4298
|
+
name: panelName,
|
|
4299
|
+
left: parseFloat(panel.style.left) || 0,
|
|
4300
|
+
top: parseFloat(panel.style.top) || 0,
|
|
4301
|
+
width: panel.offsetWidth,
|
|
4302
|
+
height: panel.offsetHeight,
|
|
4303
|
+
axesBbox: axesBbox,
|
|
4304
|
+
imgSize: imgSize,
|
|
4305
|
+
axisOffset: getAxisEdgeOffset(panel, axesBbox, edge, imgSize)
|
|
4306
|
+
});
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
if (panelInfos.length < 2) {
|
|
4310
|
+
setStatus('Need at least 2 panels with axis data for alignment', true);
|
|
4311
|
+
return;
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
// Calculate target position - use the first panel's axis position as reference
|
|
4315
|
+
const isHorizontal = ['left', 'right', 'center-h'].includes(edge);
|
|
4316
|
+
|
|
4317
|
+
if (isHorizontal) {
|
|
4318
|
+
// Align horizontally (match X positions of axis edges)
|
|
4319
|
+
// Target = first panel's axis X position in canvas coords
|
|
4320
|
+
const refPanel = panelInfos[0];
|
|
4321
|
+
const targetAxisX = refPanel.left + refPanel.axisOffset;
|
|
4322
|
+
|
|
4323
|
+
for (const info of panelInfos) {
|
|
4324
|
+
const newLeft = targetAxisX - info.axisOffset;
|
|
4325
|
+
info.el.style.left = newLeft + 'px';
|
|
4326
|
+
}
|
|
4327
|
+
} else {
|
|
4328
|
+
// Align vertically (match Y positions of axis edges)
|
|
4329
|
+
// Target = first panel's axis Y position in canvas coords
|
|
4330
|
+
const refPanel = panelInfos[0];
|
|
4331
|
+
const targetAxisY = refPanel.top + refPanel.axisOffset;
|
|
4332
|
+
|
|
4333
|
+
for (const info of panelInfos) {
|
|
4334
|
+
const newTop = targetAxisY - info.axisOffset;
|
|
4335
|
+
info.el.style.top = newTop + 'px';
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
// Update layout data
|
|
4340
|
+
updatePanelLayoutFromDOM();
|
|
4341
|
+
console.log(`Aligned ${panelInfos.length} panels by axis: ${edge}`);
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
// Stack panels vertically with Y-axis alignment
|
|
4345
|
+
function stackPanelsVertically() {
|
|
4346
|
+
const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
|
|
4347
|
+
if (panels.length < 2) {
|
|
4348
|
+
setStatus('Need multiple panels for stacking', true);
|
|
4349
|
+
return;
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
// Collect panel info with axes bboxes
|
|
4353
|
+
const panelInfos = [];
|
|
4354
|
+
for (const panel of panels) {
|
|
4355
|
+
const panelName = panel.dataset.panelName;
|
|
4356
|
+
const cache = panelBboxesCache[panelName];
|
|
4357
|
+
const axesBbox = getAxesBboxForPanel(panelName);
|
|
4358
|
+
const imgSize = cache ? cache.imgSize : null;
|
|
4359
|
+
|
|
4360
|
+
if (!axesBbox || !imgSize) {
|
|
4361
|
+
console.warn(`Panel ${panelName} has no axes bbox data`);
|
|
4362
|
+
continue;
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
panelInfos.push({
|
|
4366
|
+
el: panel,
|
|
4367
|
+
name: panelName,
|
|
4368
|
+
left: parseFloat(panel.style.left) || 0,
|
|
4369
|
+
top: parseFloat(panel.style.top) || 0,
|
|
4370
|
+
width: panel.offsetWidth,
|
|
4371
|
+
height: panel.offsetHeight,
|
|
4372
|
+
axesBbox: axesBbox,
|
|
4373
|
+
imgSize: imgSize,
|
|
4374
|
+
yAxisOffset: getAxisEdgeOffset(panel, axesBbox, 'left', imgSize)
|
|
4375
|
+
});
|
|
4376
|
+
}
|
|
4377
|
+
|
|
4378
|
+
if (panelInfos.length < 2) {
|
|
4379
|
+
setStatus('Need at least 2 panels with axis data for stacking', true);
|
|
4380
|
+
return;
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
// Sort by current vertical position
|
|
4384
|
+
panelInfos.sort((a, b) => a.top - b.top);
|
|
4385
|
+
|
|
4386
|
+
// Use first panel as reference for Y-axis alignment
|
|
4387
|
+
const refPanel = panelInfos[0];
|
|
4388
|
+
const targetAxisX = refPanel.left + refPanel.yAxisOffset;
|
|
4389
|
+
|
|
4390
|
+
// Stack panels vertically with small gap, aligned by Y-axis
|
|
4391
|
+
const gap = 10; // pixels gap between panels
|
|
4392
|
+
let currentY = refPanel.top;
|
|
4393
|
+
|
|
4394
|
+
for (let i = 0; i < panelInfos.length; i++) {
|
|
4395
|
+
const info = panelInfos[i];
|
|
4396
|
+
|
|
4397
|
+
// Align Y-axis (left edge of axes)
|
|
4398
|
+
const newLeft = targetAxisX - info.yAxisOffset;
|
|
4399
|
+
info.el.style.left = newLeft + 'px';
|
|
4400
|
+
|
|
4401
|
+
// Stack vertically
|
|
4402
|
+
info.el.style.top = currentY + 'px';
|
|
4403
|
+
currentY += info.height + gap;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
// Update layout data
|
|
4407
|
+
updatePanelLayoutFromDOM();
|
|
4408
|
+
setStatus(`Stacked ${panelInfos.length} panels with Y-axis alignment`, false);
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
// Move selected panel(s) by delta in mm
|
|
4412
|
+
function moveSelectedPanel(direction, amountMm) {
|
|
4413
|
+
const selectedPanels = document.querySelectorAll('.panel-canvas-item.active');
|
|
4414
|
+
if (selectedPanels.length === 0) {
|
|
4415
|
+
setStatus('No panel selected', true);
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
|
|
4419
|
+
const deltaX = direction === 'left' ? -amountMm : (direction === 'right' ? amountMm : 0);
|
|
4420
|
+
const deltaY = direction === 'up' ? -amountMm : (direction === 'down' ? amountMm : 0);
|
|
4421
|
+
|
|
4422
|
+
selectedPanels.forEach(panel => {
|
|
4423
|
+
const panelName = panel.dataset.panelName;
|
|
4424
|
+
|
|
4425
|
+
// Update position in pixels (canvasScale = px/mm)
|
|
4426
|
+
const currentLeft = parseFloat(panel.style.left) || 0;
|
|
4427
|
+
const currentTop = parseFloat(panel.style.top) || 0;
|
|
4428
|
+
|
|
4429
|
+
panel.style.left = (currentLeft + deltaX * canvasScale) + 'px';
|
|
4430
|
+
panel.style.top = (currentTop + deltaY * canvasScale) + 'px';
|
|
4431
|
+
|
|
4432
|
+
// Update layout data
|
|
4433
|
+
if (panelLayoutMm[panelName]) {
|
|
4434
|
+
panelLayoutMm[panelName].x_mm += deltaX;
|
|
4435
|
+
panelLayoutMm[panelName].y_mm += deltaY;
|
|
4436
|
+
layoutModified = true;
|
|
4437
|
+
}
|
|
4438
|
+
});
|
|
4439
|
+
|
|
4440
|
+
const count = selectedPanels.length;
|
|
4441
|
+
const panelText = count === 1 ? selectedPanels[0].dataset.panelName : `${count} panels`;
|
|
4442
|
+
setStatus(`Moved ${panelText} by ${amountMm}mm ${direction}`, false);
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
// Zoom canvas view
|
|
4446
|
+
let canvasZoom = 1.0;
|
|
4447
|
+
function zoomCanvas(factor) {
|
|
4448
|
+
canvasZoom *= factor;
|
|
4449
|
+
canvasZoom = Math.max(0.25, Math.min(4, canvasZoom)); // Limit 25%-400%
|
|
4450
|
+
const canvas = document.getElementById('panel-canvas');
|
|
4451
|
+
if (canvas) {
|
|
4452
|
+
canvas.style.transform = `scale(${canvasZoom})`;
|
|
4453
|
+
canvas.style.transformOrigin = 'top left';
|
|
4454
|
+
}
|
|
4455
|
+
setStatus(`Zoom: ${Math.round(canvasZoom * 100)}%`, false);
|
|
4456
|
+
}
|
|
4457
|
+
|
|
4458
|
+
// Fit canvas to window
|
|
4459
|
+
function fitCanvasToWindow() {
|
|
4460
|
+
canvasZoom = 1.0;
|
|
4461
|
+
const canvas = document.getElementById('panel-canvas');
|
|
4462
|
+
if (canvas) {
|
|
4463
|
+
canvas.style.transform = 'scale(1)';
|
|
4464
|
+
}
|
|
4465
|
+
setStatus('Fit to window', false);
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
// Resize canvas (actual size, not view)
|
|
4469
|
+
function resizeCanvas(factor) {
|
|
4470
|
+
const canvas = document.getElementById('panel-canvas');
|
|
4471
|
+
if (!canvas) return;
|
|
4472
|
+
const currentWidth = canvas.offsetWidth;
|
|
4473
|
+
const currentHeight = canvas.offsetHeight;
|
|
4474
|
+
canvas.style.width = (currentWidth * factor) + 'px';
|
|
4475
|
+
canvas.style.minHeight = (currentHeight * factor) + 'px';
|
|
4476
|
+
setStatus(`Canvas: ${Math.round(currentWidth * factor)}x${Math.round(currentHeight * factor)}px`, false);
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
// Align panels
|
|
4480
|
+
function alignPanels(mode) {
|
|
4481
|
+
const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
|
|
4482
|
+
if (panels.length < 2) return;
|
|
4483
|
+
|
|
4484
|
+
// Get bounds
|
|
4485
|
+
const bounds = panels.map(p => ({
|
|
4486
|
+
el: p,
|
|
4487
|
+
left: parseFloat(p.style.left) || 0,
|
|
4488
|
+
top: parseFloat(p.style.top) || 0,
|
|
4489
|
+
width: p.offsetWidth,
|
|
4490
|
+
height: p.offsetHeight
|
|
4491
|
+
}));
|
|
4492
|
+
|
|
4493
|
+
let targetValue;
|
|
4494
|
+
switch(mode) {
|
|
4495
|
+
case 'left':
|
|
4496
|
+
targetValue = Math.min(...bounds.map(b => b.left));
|
|
4497
|
+
bounds.forEach(b => { b.el.style.left = targetValue + 'px'; });
|
|
4498
|
+
break;
|
|
4499
|
+
case 'right':
|
|
4500
|
+
targetValue = Math.max(...bounds.map(b => b.left + b.width));
|
|
4501
|
+
bounds.forEach(b => { b.el.style.left = (targetValue - b.width) + 'px'; });
|
|
4502
|
+
break;
|
|
4503
|
+
case 'top':
|
|
4504
|
+
targetValue = Math.min(...bounds.map(b => b.top));
|
|
4505
|
+
bounds.forEach(b => { b.el.style.top = targetValue + 'px'; });
|
|
4506
|
+
break;
|
|
4507
|
+
case 'bottom':
|
|
4508
|
+
targetValue = Math.max(...bounds.map(b => b.top + b.height));
|
|
4509
|
+
bounds.forEach(b => { b.el.style.top = (targetValue - b.height) + 'px'; });
|
|
4510
|
+
break;
|
|
4511
|
+
case 'center-h':
|
|
4512
|
+
targetValue = bounds.reduce((sum, b) => sum + b.left + b.width/2, 0) / bounds.length;
|
|
4513
|
+
bounds.forEach(b => { b.el.style.left = (targetValue - b.width/2) + 'px'; });
|
|
4514
|
+
break;
|
|
4515
|
+
case 'center-v':
|
|
4516
|
+
targetValue = bounds.reduce((sum, b) => sum + b.top + b.height/2, 0) / bounds.length;
|
|
4517
|
+
bounds.forEach(b => { b.el.style.top = (targetValue - b.height/2) + 'px'; });
|
|
4518
|
+
break;
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
// Update layout data
|
|
4522
|
+
updatePanelLayoutFromDOM();
|
|
4523
|
+
setStatus(`Aligned panels: ${mode}`, false);
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
// Distribute panels evenly
|
|
4527
|
+
function distributePanels(direction) {
|
|
4528
|
+
const panels = Array.from(document.querySelectorAll('.panel-canvas-item'));
|
|
4529
|
+
if (panels.length < 3) {
|
|
4530
|
+
setStatus('Need at least 3 panels to distribute', true);
|
|
4531
|
+
return;
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
const bounds = panels.map(p => ({
|
|
4535
|
+
el: p,
|
|
4536
|
+
left: parseFloat(p.style.left) || 0,
|
|
4537
|
+
top: parseFloat(p.style.top) || 0,
|
|
4538
|
+
width: p.offsetWidth,
|
|
4539
|
+
height: p.offsetHeight
|
|
4540
|
+
}));
|
|
4541
|
+
|
|
4542
|
+
if (direction === 'horizontal') {
|
|
4543
|
+
bounds.sort((a, b) => a.left - b.left);
|
|
4544
|
+
const totalWidth = bounds.reduce((sum, b) => sum + b.width, 0);
|
|
4545
|
+
const start = bounds[0].left;
|
|
4546
|
+
const end = bounds[bounds.length - 1].left + bounds[bounds.length - 1].width;
|
|
4547
|
+
const gap = (end - start - totalWidth) / (bounds.length - 1);
|
|
4548
|
+
|
|
4549
|
+
let currentX = start;
|
|
4550
|
+
bounds.forEach(b => {
|
|
4551
|
+
b.el.style.left = currentX + 'px';
|
|
4552
|
+
currentX += b.width + gap;
|
|
4553
|
+
});
|
|
4554
|
+
} else {
|
|
4555
|
+
bounds.sort((a, b) => a.top - b.top);
|
|
4556
|
+
const totalHeight = bounds.reduce((sum, b) => sum + b.height, 0);
|
|
4557
|
+
const start = bounds[0].top;
|
|
4558
|
+
const end = bounds[bounds.length - 1].top + bounds[bounds.length - 1].height;
|
|
4559
|
+
const gap = (end - start - totalHeight) / (bounds.length - 1);
|
|
4560
|
+
|
|
4561
|
+
let currentY = start;
|
|
4562
|
+
bounds.forEach(b => {
|
|
4563
|
+
b.el.style.top = currentY + 'px';
|
|
4564
|
+
currentY += b.height + gap;
|
|
4565
|
+
});
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
updatePanelLayoutFromDOM();
|
|
4569
|
+
setStatus(`Distributed panels: ${direction}`, false);
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
// Update layout data from DOM positions
|
|
4573
|
+
function updatePanelLayoutFromDOM() {
|
|
4574
|
+
document.querySelectorAll('.panel-canvas-item').forEach(panel => {
|
|
4575
|
+
const name = panel.dataset.panelName;
|
|
4576
|
+
if (panelLayoutMm[name]) {
|
|
4577
|
+
panelLayoutMm[name].x_mm = parseFloat(panel.style.left) / canvasScale;
|
|
4578
|
+
panelLayoutMm[name].y_mm = parseFloat(panel.style.top) / canvasScale;
|
|
4579
|
+
}
|
|
4580
|
+
});
|
|
4581
|
+
layoutModified = true;
|
|
4582
|
+
autoSaveLayout();
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
// Bring selected panel(s) to front
|
|
4586
|
+
function bringPanelToFront() {
|
|
4587
|
+
const selectedPanels = document.querySelectorAll('.panel-canvas-item.active');
|
|
4588
|
+
if (selectedPanels.length === 0) return;
|
|
4589
|
+
const maxZ = Math.max(...Array.from(document.querySelectorAll('.panel-canvas-item')).map(p => parseInt(p.style.zIndex) || 0));
|
|
4590
|
+
selectedPanels.forEach((panel, i) => {
|
|
4591
|
+
panel.style.zIndex = maxZ + 1 + i;
|
|
4592
|
+
});
|
|
4593
|
+
setStatus(`Brought ${selectedPanels.length > 1 ? selectedPanels.length + ' panels' : 'panel'} to front`, false);
|
|
4594
|
+
}
|
|
4595
|
+
|
|
4596
|
+
// Send selected panel(s) to back
|
|
4597
|
+
function sendPanelToBack() {
|
|
4598
|
+
const selectedPanels = document.querySelectorAll('.panel-canvas-item.active');
|
|
4599
|
+
if (selectedPanels.length === 0) return;
|
|
4600
|
+
const minZ = Math.min(...Array.from(document.querySelectorAll('.panel-canvas-item')).map(p => parseInt(p.style.zIndex) || 0));
|
|
4601
|
+
selectedPanels.forEach((panel, i) => {
|
|
4602
|
+
panel.style.zIndex = minZ - selectedPanels.length + i;
|
|
4603
|
+
});
|
|
4604
|
+
setStatus(`Sent ${selectedPanels.length > 1 ? selectedPanels.length + ' panels' : 'panel'} to back`, false);
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
// Deselect all panels
|
|
4608
|
+
function deselectAllPanels() {
|
|
4609
|
+
document.querySelectorAll('.panel-canvas-item.active').forEach(p => {
|
|
4610
|
+
p.classList.remove('active');
|
|
4611
|
+
});
|
|
4612
|
+
// Also clear element selection in single-panel view
|
|
4613
|
+
if (typeof selectedElement !== 'undefined') {
|
|
4614
|
+
selectedElement = null;
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
|
|
4618
|
+
// Select all panels
|
|
4619
|
+
function selectAllPanels() {
|
|
4620
|
+
const panels = document.querySelectorAll('.panel-canvas-item');
|
|
4621
|
+
panels.forEach(p => p.classList.add('active'));
|
|
4622
|
+
setStatus(`Selected ${panels.length} panels`, false);
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
// Toggle grid visibility
|
|
4626
|
+
let gridVisible = true;
|
|
4627
|
+
function toggleGridVisibility() {
|
|
4628
|
+
gridVisible = !gridVisible;
|
|
4629
|
+
const gridElements = document.querySelectorAll('.canvas-grid, .grid-lines, .ruler-marks');
|
|
4630
|
+
gridElements.forEach(el => {
|
|
4631
|
+
el.style.opacity = gridVisible ? '1' : '0';
|
|
4632
|
+
});
|
|
4633
|
+
// Also toggle the canvas background grid if using CSS grid
|
|
4634
|
+
const canvasContainer = document.querySelector('.panel-canvas, #canvas-container');
|
|
4635
|
+
if (canvasContainer) {
|
|
4636
|
+
if (gridVisible) {
|
|
4637
|
+
canvasContainer.classList.remove('hide-grid');
|
|
4638
|
+
} else {
|
|
4639
|
+
canvasContainer.classList.add('hide-grid');
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
setStatus(gridVisible ? 'Grid visible' : 'Grid hidden', false);
|
|
4643
|
+
}
|
|
4644
|
+
|
|
4645
|
+
// Undo/Redo stacks
|
|
4646
|
+
let undoStack = [];
|
|
4647
|
+
let redoStack = [];
|
|
4648
|
+
|
|
4649
|
+
function undoLastChange() {
|
|
4650
|
+
if (undoStack.length === 0) {
|
|
4651
|
+
setStatus('Nothing to undo', true);
|
|
4652
|
+
return;
|
|
4653
|
+
}
|
|
4654
|
+
const state = undoStack.pop();
|
|
4655
|
+
redoStack.push(JSON.stringify(overrides));
|
|
4656
|
+
overrides = JSON.parse(state);
|
|
4657
|
+
updatePreview();
|
|
4658
|
+
setStatus('Undo', false);
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
function redoLastChange() {
|
|
4662
|
+
if (redoStack.length === 0) {
|
|
4663
|
+
setStatus('Nothing to redo', true);
|
|
4664
|
+
return;
|
|
4665
|
+
}
|
|
4666
|
+
const state = redoStack.pop();
|
|
4667
|
+
undoStack.push(JSON.stringify(overrides));
|
|
4668
|
+
overrides = JSON.parse(state);
|
|
4669
|
+
updatePreview();
|
|
4670
|
+
setStatus('Redo', false);
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
// Save state for undo before changes
|
|
4674
|
+
function saveUndoState() {
|
|
4675
|
+
undoStack.push(JSON.stringify(overrides));
|
|
4676
|
+
if (undoStack.length > 50) undoStack.shift(); // Limit stack size
|
|
4677
|
+
redoStack = []; // Clear redo on new change
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4680
|
+
// Delete selected element override
|
|
4681
|
+
function deleteSelectedOverride() {
|
|
4682
|
+
if (!selectedElement) return;
|
|
4683
|
+
saveUndoState();
|
|
4684
|
+
if (overrides.element_overrides && overrides.element_overrides[selectedElement]) {
|
|
4685
|
+
delete overrides.element_overrides[selectedElement];
|
|
4686
|
+
updatePreview();
|
|
4687
|
+
setStatus(`Deleted override for ${selectedElement}`, false);
|
|
4688
|
+
}
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
// Show keyboard shortcuts help
|
|
4692
|
+
function showShortcutHelp() {
|
|
4693
|
+
const helpHtml = `
|
|
4694
|
+
<div id="shortcut-modal" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: flex; align-items: center; justify-content: center;" onclick="this.remove()">
|
|
4695
|
+
<div style="background: var(--bg-secondary); padding: 24px; border-radius: 8px; max-width: 700px; max-height: 80vh; overflow-y: auto; color: var(--text-primary);" onclick="event.stopPropagation()">
|
|
4696
|
+
<h2 style="margin-top: 0; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">⌨️ Keyboard Shortcuts</h2>
|
|
4697
|
+
|
|
4698
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
|
4699
|
+
<div>
|
|
4700
|
+
<h4 style="color: var(--accent-primary);">Basic</h4>
|
|
4701
|
+
<div><kbd>Ctrl+S</kbd> Save</div>
|
|
4702
|
+
<div><kbd>Ctrl+Z</kbd> Undo</div>
|
|
4703
|
+
<div><kbd>Ctrl+Y</kbd> Redo</div>
|
|
4704
|
+
<div><kbd>Del</kbd> Delete override</div>
|
|
4705
|
+
<div><kbd>Esc</kbd> Deselect / Cancel</div>
|
|
4706
|
+
<div><kbd>Ctrl+A</kbd> Select all panels</div>
|
|
4707
|
+
|
|
4708
|
+
<h4 style="color: var(--accent-primary); margin-top: 16px;">Selection</h4>
|
|
4709
|
+
<div><kbd>Click</kbd> Select panel</div>
|
|
4710
|
+
<div><kbd>Ctrl+Click</kbd> Multi-select</div>
|
|
4711
|
+
<div><kbd>Right-Click</kbd> Context menu</div>
|
|
4712
|
+
|
|
4713
|
+
<h4 style="color: var(--accent-primary); margin-top: 16px;">Movement</h4>
|
|
4714
|
+
<div><kbd>↑↓←→</kbd> Move panel 1mm</div>
|
|
4715
|
+
<div><kbd>Shift+↑↓←→</kbd> Move panel 5mm</div>
|
|
4716
|
+
</div>
|
|
4717
|
+
|
|
4718
|
+
<div>
|
|
4719
|
+
<h4 style="color: var(--accent-primary);">View</h4>
|
|
4720
|
+
<div><kbd>+</kbd> Zoom in</div>
|
|
4721
|
+
<div><kbd>-</kbd> Zoom out</div>
|
|
4722
|
+
<div><kbd>0</kbd> Fit to window</div>
|
|
4723
|
+
<div><kbd>G</kbd> Toggle grid</div>
|
|
4724
|
+
<div><kbd>Ctrl++</kbd> Increase canvas</div>
|
|
4725
|
+
<div><kbd>Ctrl+-</kbd> Decrease canvas</div>
|
|
4726
|
+
|
|
4727
|
+
<h4 style="color: var(--accent-primary); margin-top: 16px;">Arrange</h4>
|
|
4728
|
+
<div><kbd>Alt+F</kbd> Bring to front</div>
|
|
4729
|
+
<div><kbd>Alt+B</kbd> Send to back</div>
|
|
4730
|
+
</div>
|
|
4731
|
+
</div>
|
|
4732
|
+
|
|
4733
|
+
<h4 style="color: var(--accent-primary); margin-top: 16px;">Alignment (Alt+A → ...)</h4>
|
|
4734
|
+
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
4735
|
+
<div><kbd>L</kbd> Left</div>
|
|
4736
|
+
<div><kbd>R</kbd> Right</div>
|
|
4737
|
+
<div><kbd>T</kbd> Top</div>
|
|
4738
|
+
<div><kbd>B</kbd> Bottom</div>
|
|
4739
|
+
<div><kbd>C</kbd> Center H</div>
|
|
4740
|
+
<div><kbd>M</kbd> Center V</div>
|
|
4741
|
+
<div><kbd>H</kbd> Distribute H</div>
|
|
4742
|
+
<div><kbd>V</kbd> Distribute V</div>
|
|
4743
|
+
</div>
|
|
4744
|
+
|
|
4745
|
+
<h4 style="color: var(--accent-primary); margin-top: 16px;">Axis Alignment (Alt+Shift+A → ...)</h4>
|
|
4746
|
+
<p style="font-size: 0.85em; color: var(--text-muted); margin-top: 4px;">Aligns panels by plot axis edges, not bounding boxes</p>
|
|
4747
|
+
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
4748
|
+
<div><kbd>L</kbd> Y-axis (left)</div>
|
|
4749
|
+
<div><kbd>R</kbd> Right edge</div>
|
|
4750
|
+
<div><kbd>T</kbd> Top edge</div>
|
|
4751
|
+
<div><kbd>B</kbd> X-axis (bottom)</div>
|
|
4752
|
+
<div><kbd>C</kbd> Axes center H</div>
|
|
4753
|
+
<div><kbd>M</kbd> Axes center V</div>
|
|
4754
|
+
<div><kbd>S</kbd> Stack vertically</div>
|
|
4755
|
+
</div>
|
|
4756
|
+
|
|
4757
|
+
<div style="margin-top: 20px; text-align: center; color: var(--text-muted);">
|
|
4758
|
+
Press <kbd>?</kbd> or <kbd>F1</kbd> anytime to show this help
|
|
4759
|
+
</div>
|
|
4760
|
+
</div>
|
|
4761
|
+
</div>`;
|
|
4762
|
+
document.body.insertAdjacentHTML('beforeend', helpHtml);
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
// Add kbd styling
|
|
4766
|
+
const kbdStyle = document.createElement('style');
|
|
4767
|
+
kbdStyle.textContent = `
|
|
4768
|
+
kbd {
|
|
4769
|
+
background: var(--bg-tertiary, #333);
|
|
4770
|
+
border: 1px solid var(--border-color, #555);
|
|
4771
|
+
border-radius: 3px;
|
|
4772
|
+
padding: 2px 6px;
|
|
4773
|
+
font-family: monospace;
|
|
4774
|
+
font-size: 0.85em;
|
|
4775
|
+
margin-right: 8px;
|
|
4776
|
+
}
|
|
4777
|
+
`;
|
|
4778
|
+
document.head.appendChild(kbdStyle);
|
|
4779
|
+
|
|
4780
|
+
// =============================================================================
|
|
4781
|
+
// Right-Click Context Menu
|
|
4782
|
+
// =============================================================================
|
|
4783
|
+
let contextMenu = null;
|
|
4784
|
+
|
|
4785
|
+
function showContextMenu(e, panelName) {
|
|
4786
|
+
e.preventDefault();
|
|
4787
|
+
hideContextMenu();
|
|
4788
|
+
|
|
4789
|
+
const selectedCount = document.querySelectorAll('.panel-canvas-item.active').length;
|
|
4790
|
+
const hasSelection = selectedCount > 0;
|
|
4791
|
+
|
|
4792
|
+
const menu = document.createElement('div');
|
|
4793
|
+
menu.id = 'canvas-context-menu';
|
|
4794
|
+
menu.className = 'context-menu';
|
|
4795
|
+
menu.innerHTML = `
|
|
4796
|
+
<div class="context-menu-item" onclick="selectAllPanels(); hideContextMenu();">
|
|
4797
|
+
<span class="context-menu-icon">⬚</span> Select All <span class="context-menu-shortcut">Ctrl+A</span>
|
|
4798
|
+
</div>
|
|
4799
|
+
<div class="context-menu-item ${!hasSelection ? 'disabled' : ''}" onclick="${hasSelection ? 'deselectAllPanels(); hideContextMenu();' : ''}">
|
|
4800
|
+
<span class="context-menu-icon">○</span> Deselect All <span class="context-menu-shortcut">Esc</span>
|
|
4801
|
+
</div>
|
|
4802
|
+
<div class="context-menu-divider"></div>
|
|
4803
|
+
<div class="context-menu-item ${!hasSelection ? 'disabled' : ''}" onclick="${hasSelection ? 'bringPanelToFront(); hideContextMenu();' : ''}">
|
|
4804
|
+
<span class="context-menu-icon">↑</span> Bring to Front <span class="context-menu-shortcut">Alt+F</span>
|
|
4805
|
+
</div>
|
|
4806
|
+
<div class="context-menu-item ${!hasSelection ? 'disabled' : ''}" onclick="${hasSelection ? 'sendPanelToBack(); hideContextMenu();' : ''}">
|
|
4807
|
+
<span class="context-menu-icon">↓</span> Send to Back <span class="context-menu-shortcut">Alt+B</span>
|
|
4808
|
+
</div>
|
|
4809
|
+
<div class="context-menu-divider"></div>
|
|
4810
|
+
<div class="context-menu-submenu">
|
|
4811
|
+
<div class="context-menu-item">
|
|
4812
|
+
<span class="context-menu-icon">≡</span> Align <span class="context-menu-arrow">▶</span>
|
|
4813
|
+
</div>
|
|
4814
|
+
<div class="context-submenu">
|
|
4815
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('left'); hideContextMenu();" : ''}">Left</div>
|
|
4816
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('right'); hideContextMenu();" : ''}">Right</div>
|
|
4817
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('top'); hideContextMenu();" : ''}">Top</div>
|
|
4818
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('bottom'); hideContextMenu();" : ''}">Bottom</div>
|
|
4819
|
+
<div class="context-menu-divider"></div>
|
|
4820
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('center-h'); hideContextMenu();" : ''}">Center H</div>
|
|
4821
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanels('center-v'); hideContextMenu();" : ''}">Center V</div>
|
|
4822
|
+
</div>
|
|
4823
|
+
</div>
|
|
4824
|
+
<div class="context-menu-submenu">
|
|
4825
|
+
<div class="context-menu-item">
|
|
4826
|
+
<span class="context-menu-icon">⊞</span> Align by Axis <span class="context-menu-arrow">▶</span>
|
|
4827
|
+
</div>
|
|
4828
|
+
<div class="context-submenu">
|
|
4829
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanelsByAxis('left'); hideContextMenu();" : ''}">Y-Axis (Left)</div>
|
|
4830
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "alignPanelsByAxis('bottom'); hideContextMenu();" : ''}">X-Axis (Bottom)</div>
|
|
4831
|
+
<div class="context-menu-divider"></div>
|
|
4832
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "stackPanelsVertically(); hideContextMenu();" : ''}">Stack Vertically</div>
|
|
4833
|
+
</div>
|
|
4834
|
+
</div>
|
|
4835
|
+
<div class="context-menu-submenu">
|
|
4836
|
+
<div class="context-menu-item">
|
|
4837
|
+
<span class="context-menu-icon">⇔</span> Distribute <span class="context-menu-arrow">▶</span>
|
|
4838
|
+
</div>
|
|
4839
|
+
<div class="context-submenu">
|
|
4840
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "distributePanels('horizontal'); hideContextMenu();" : ''}">Horizontal</div>
|
|
4841
|
+
<div class="context-menu-item ${selectedCount < 2 ? 'disabled' : ''}" onclick="${selectedCount >= 2 ? "distributePanels('vertical'); hideContextMenu();" : ''}">Vertical</div>
|
|
4842
|
+
</div>
|
|
4843
|
+
</div>
|
|
4844
|
+
<div class="context-menu-divider"></div>
|
|
4845
|
+
<div class="context-menu-item" onclick="toggleGridVisibility(); hideContextMenu();">
|
|
4846
|
+
<span class="context-menu-icon">⊞</span> Toggle Grid <span class="context-menu-shortcut">G</span>
|
|
4847
|
+
</div>
|
|
4848
|
+
<div class="context-menu-divider"></div>
|
|
4849
|
+
<div class="context-menu-item" onclick="showShortcutHelp(); hideContextMenu();">
|
|
4850
|
+
<span class="context-menu-icon">⌨</span> Keyboard Shortcuts <span class="context-menu-shortcut">?</span>
|
|
4851
|
+
</div>
|
|
4852
|
+
`;
|
|
4853
|
+
|
|
4854
|
+
// Position menu at cursor
|
|
4855
|
+
menu.style.left = e.clientX + 'px';
|
|
4856
|
+
menu.style.top = e.clientY + 'px';
|
|
4857
|
+
|
|
4858
|
+
document.body.appendChild(menu);
|
|
4859
|
+
contextMenu = menu;
|
|
4860
|
+
|
|
4861
|
+
// Adjust position if menu goes off screen
|
|
4862
|
+
const rect = menu.getBoundingClientRect();
|
|
4863
|
+
if (rect.right > window.innerWidth) {
|
|
4864
|
+
menu.style.left = (window.innerWidth - rect.width - 5) + 'px';
|
|
4865
|
+
}
|
|
4866
|
+
if (rect.bottom > window.innerHeight) {
|
|
4867
|
+
menu.style.top = (window.innerHeight - rect.height - 5) + 'px';
|
|
4868
|
+
}
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
function hideContextMenu() {
|
|
4872
|
+
if (contextMenu) {
|
|
4873
|
+
contextMenu.remove();
|
|
4874
|
+
contextMenu = null;
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
|
|
4878
|
+
// Close context menu on click outside
|
|
4879
|
+
document.addEventListener('click', (e) => {
|
|
4880
|
+
if (contextMenu && !contextMenu.contains(e.target)) {
|
|
4881
|
+
hideContextMenu();
|
|
4882
|
+
}
|
|
4883
|
+
});
|
|
4884
|
+
|
|
4885
|
+
// Close context menu on Escape
|
|
4886
|
+
document.addEventListener('keydown', (e) => {
|
|
4887
|
+
if (e.key === 'Escape' && contextMenu) {
|
|
4888
|
+
hideContextMenu();
|
|
4889
|
+
}
|
|
4890
|
+
});
|
|
4891
|
+
|
|
4892
|
+
// Attach context menu to canvas
|
|
4893
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
4894
|
+
const canvas = document.getElementById('panel-canvas');
|
|
4895
|
+
if (canvas) {
|
|
4896
|
+
canvas.addEventListener('contextmenu', (e) => {
|
|
4897
|
+
// Check if right-click is on a panel
|
|
4898
|
+
const panel = e.target.closest('.panel-canvas-item');
|
|
4899
|
+
const panelName = panel ? panel.dataset.panelName : null;
|
|
4900
|
+
|
|
4901
|
+
// If clicking on a panel that's not selected, select it
|
|
4902
|
+
if (panel && !panel.classList.contains('active')) {
|
|
4903
|
+
if (!e.ctrlKey && !e.metaKey) {
|
|
4904
|
+
deselectAllPanels();
|
|
4905
|
+
}
|
|
4906
|
+
panel.classList.add('active');
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
showContextMenu(e, panelName);
|
|
4910
|
+
});
|
|
3114
4911
|
}
|
|
3115
4912
|
});
|
|
3116
4913
|
|