diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java index eaa68bb..d11f780 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java @@ -5,11 +5,13 @@ import com.chuangzhou.vivid2D.render.awt.manager.ThumbnailManager; import com.chuangzhou.vivid2D.render.awt.manager.data.LayerOperationManagerData; import com.chuangzhou.vivid2D.render.awt.util.*; import com.chuangzhou.vivid2D.render.awt.util.renderer.LayerCellRenderer; +// 确保 LayerReorderTransferHandler 被正确导入,它处理内部重排 import com.chuangzhou.vivid2D.render.awt.util.renderer.LayerReorderTransferHandler; import com.chuangzhou.vivid2D.render.model.Model2D; import com.chuangzhou.vivid2D.render.model.ModelPart; import com.chuangzhou.vivid2D.render.model.util.Mesh2D; import com.chuangzhou.vivid2D.render.model.util.Texture; +import com.chuangzhou.vivid2D.window.MainWindow; import org.joml.Vector2f; import javax.imageio.ImageIO; @@ -18,6 +20,8 @@ import javax.swing.border.EmptyBorder; import javax.swing.border.TitledBorder; import javax.swing.plaf.basic.BasicSliderUI; import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.geom.RoundRectangle2D; @@ -34,10 +38,13 @@ import java.util.List; import java.util.Map; // 引入 JnaFileChooser import jnafilechooser.api.JnaFileChooser; -// 引入拖放相关的类 +// 引入拖放和快捷键相关的类 import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.Transferable; import javax.swing.TransferHandler; +import javax.swing.AbstractAction; +import javax.swing.Action; +import javax.swing.KeyStroke; public class ModelLayerPanel extends JPanel { @@ -99,9 +106,8 @@ public class ModelLayerPanel extends JPanel { setupModernLookAndFeel(); this.thumbnailManager = new ThumbnailManager(renderPanel); - // --- 新增:设置外部文件拖放处理器 --- - this.setTransferHandler(new FileDropTransferHandler()); - // --------------------------------- + // FIX: 移除在 this 上的 TransferHandler,因为 JList 将会覆盖它 + // this.setTransferHandler(new FileDropTransferHandler()); if (this.model != null) { this.psdImporter = new PSDImporter(model, renderPanel, this); @@ -125,6 +131,19 @@ public class ModelLayerPanel extends JPanel { } } + private void setupWindowFileDropHandler() { + SwingUtilities.invokeLater(() -> { + Window window = SwingUtilities.getWindowAncestor(this); + // 确保找到的 window 是 RootPaneContainer (如 JFrame, JDialog) + if (window instanceof MainWindow mainWindow) { + JComponent contentPane = (JComponent) mainWindow.getContentPane(); + if (contentPane != null) { + contentPane.setTransferHandler(new FileDropOnlyTransferHandler()); + } + } + }); + } + public void loadMetadata() { String modelDataPath = renderPanel.getGlContextManager().getModelPath() + ".data"; try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(modelDataPath))) { @@ -185,6 +204,10 @@ public class ModelLayerPanel extends JPanel { gbc.fill = GridBagConstraints.BOTH; add(centerScrollPane, gbc); + setupWindowFileDropHandler(); + // 绑定快捷键 + setupKeyBindings(layerList); + JPanel controlPanel = createControlPanel(); gbc.gridy = 2; gbc.weighty = 0.0; @@ -193,9 +216,37 @@ public class ModelLayerPanel extends JPanel { add(controlPanel, gbc); } + /** + * 设置快捷键绑定。 + * @param list JList 组件 + */ + private void setupKeyBindings(JList list) { + // 关键更改:从 JList 获取 InputMap,但使用 WHEN_IN_FOCUSED_WINDOW 模式 + // 这样,只要包含这个 ModelLayerPanel 的窗口处于焦点状态,快捷键就会生效。 + InputMap inputMap = list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); + + // 获取组件的 ActionMap + ActionMap actionMap = list.getActionMap(); + + // 绑定 Delete/Backspace 键到删除操作 + KeyStroke deleteKey = KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0); + KeyStroke backspaceKey = KeyStroke.getKeyStroke(KeyEvent.VK_BACK_SPACE, 0); + + inputMap.put(deleteKey, "deleteLayer"); + inputMap.put(backspaceKey, "deleteLayer"); + Action deleteAction = new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + onRemoveLayer(); + } + }; + + actionMap.put("deleteLayer", deleteAction); + } + private JList createModernList() { JList list = new JList<>(listModel); - // 【修正 1:启用多选】 + // 启用多选 list.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); list.setBackground(SURFACE_COLOR); list.setForeground(TEXT_COLOR); @@ -205,8 +256,10 @@ public class ModelLayerPanel extends JPanel { cellRenderer.attachMouseListener(list, listModel); list.setCellRenderer(cellRenderer); list.setDragEnabled(true); - // 【修正 2:使用多选 TransferHandler】 - list.setTransferHandler(new LayerReorderTransferHandler(this)); + + // FIX: 使用新的复合 TransferHandler 统一处理文件拖放和内部重排 + list.setTransferHandler(new CompositeLayerTransferHandler(this)); + list.setDropMode(DropMode.INSERT); list.addMouseListener(new MouseAdapter() { @Override @@ -436,7 +489,7 @@ public class ModelLayerPanel extends JPanel { /** * 【新增方法】执行多选拖拽后的图层块重排序操作。 - * 供 LayerReorderTransferHandler 调用。 + * 供 CompositeLayerTransferHandler (原 LayerReorderTransferHandler) 调用。 * @param srcIndices 列表中的视觉源索引数组(从上到下,已排序)。 * @param dropIndex 列表中的视觉目标插入索引。 */ @@ -486,7 +539,7 @@ public class ModelLayerPanel extends JPanel { } removeButton.setEnabled(hasSelection); - // 【修正 3:多选时启用上下移动按钮】 + // 多选时启用上下移动按钮 upButton.setEnabled(hasSelection); downButton.setEnabled(hasSelection); // 绑定贴图仍然只在单选时有意义 @@ -608,9 +661,10 @@ public class ModelLayerPanel extends JPanel { } private void createEmptyPart() { - String name = JOptionPane.showInputDialog(this, "新图层名称:", "新图层"); + String name = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "新图层名称:", "新图层"); if (name == null || name.trim().isEmpty()) return; + // 传入名称 operationManager.addLayer(name); reloadFromModel(); @@ -634,7 +688,7 @@ public class ModelLayerPanel extends JPanel { private void showRenameDialog(ModelPart part) { String newName = (String) JOptionPane.showInputDialog( - this, + SwingUtilities.getWindowAncestor(this), "输入新名称:", "重命名图层", JOptionPane.PLAIN_MESSAGE, @@ -793,7 +847,19 @@ public class ModelLayerPanel extends JPanel { renderPanel.getGlContextManager().executeInGLContext(() -> { try { Texture texture = Texture.createFromFile(texName, filePath); - meshToBind.setTexture(texture); + List partMeshes = sel.getMeshes(); + Mesh2D actualMesh = null; + if (partMeshes != null && !partMeshes.isEmpty()) { + actualMesh = partMeshes.get(partMeshes.size() - 1); + } + + if (actualMesh != null) { + actualMesh.setTexture(texture); + } else { + // 修复:将无法解析的 'mesh' 替换为正确的局部变量 'meshToBind' + meshToBind.setTexture(texture); + } + model.addTexture(texture); model.markNeedsUpdate(); } catch (Throwable ex) { @@ -822,27 +888,32 @@ public class ModelLayerPanel extends JPanel { } private void onRemoveLayer() { - // 修正:支持删除多个选中的图层 List selectedParts = layerList.getSelectedValuesList(); if (selectedParts.isEmpty()) return; String names = selectedParts.stream().map(ModelPart::getName).collect(java.util.stream.Collectors.joining("、")); - int r = JOptionPane.showConfirmDialog(this, "确认删除图层:" + names + " ?", "确认删除", JOptionPane.YES_NO_OPTION); + int r = JOptionPane.showConfirmDialog(SwingUtilities.getWindowAncestor(this), "确认删除图层:" + names + " ?", "确认删除", JOptionPane.YES_NO_OPTION); if (r != JOptionPane.YES_OPTION) return; try { for(ModelPart part : selectedParts) { operationManager.removeLayer(part); thumbnailManager.removeThumbnail(part); - // 仅移除第一个选中项的参数管理(这是一个简化,实际应用中可能需要遍历移除) if (part == selectedParts.get(0)) { - renderPanel.getParametersManagement().removeParameter(part, "all"); - renderPanel.getGlContextManager().executeInGLContext(() -> renderPanel.getParametersManagement().removeParameter(part, "all")); + if (renderPanel != null && renderPanel.getParametersManagement() != null) { + renderPanel.getParametersManagement().removeParameter(part, "all"); + renderPanel.getGlContextManager().executeInGLContext(() -> { + if (renderPanel != null && renderPanel.getParametersManagement() != null) { + renderPanel.getParametersManagement().removeParameter(part, "all"); + } + }); + } } } reloadFromModel(); } catch (Exception ex) { - JOptionPane.showMessageDialog(this, "删除失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); + // 修复:将父组件改为顶层窗口 + JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(this), "删除失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } @@ -900,12 +971,57 @@ public class ModelLayerPanel extends JPanel { try { BufferedImage img = ImageIO.read(f); if (img == null) throw new IOException("无法读取图片:" + f.getAbsolutePath()); - String name = JOptionPane.showInputDialog(this, "新图层名称:", f.getName()); + + // 1. 获取用户输入的名称(或默认文件名) + String name = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "新图层名称:", f.getName()); if (name == null || name.trim().isEmpty()) name = f.getName(); + // --- 修复:确保图层名称唯一性 start --- + // 解决 "Part already exists" 错误,确保 ModelPart 名称唯一 + Map partMap = getModelPartMap(); + if (partMap != null) { + String uniqueName = name; + int counter = 1; + + // 分离文件名和扩展名 + String nameWithoutExt = name; + String extension = ""; + int dotIndex = name.lastIndexOf('.'); + if (dotIndex > 0) { // 确保点不在开头 + nameWithoutExt = name.substring(0, dotIndex); + extension = name.substring(dotIndex); + } + + // 剥离已有的 (数字) 部分,从干净的基础名开始计数 + String baseNameForCounter = nameWithoutExt; + int bracketStart = baseNameForCounter.lastIndexOf('('); + int bracketEnd = baseNameForCounter.lastIndexOf(')'); + + if (bracketStart > 0 && bracketEnd == baseNameForCounter.length() - 1) { + try { + // 检查括号中的内容是否为数字 + Integer.parseInt(baseNameForCounter.substring(bracketStart + 1, bracketEnd).trim()); + baseNameForCounter = baseNameForCounter.substring(0, bracketStart).trim(); + } catch (NumberFormatException ignored) { + // 不是数字,则保持不变 + } + } + + // 检查并生成唯一名称 + while (partMap.containsKey(uniqueName)) { + uniqueName = baseNameForCounter + " (" + counter + ")" + extension; + counter++; + if (counter > 100) { // 避免无限循环 + throw new IllegalStateException("无法生成唯一图层名称。"); + } + } + name = uniqueName; // 使用最终的唯一名称 + } + // --- 修复:确保图层名称唯一性 end --- + ModelPart part = model.createPart(name); - Mesh2D mesh = MeshTextureUtil.createQuadForImage(img, name + "_mesh"); - mesh.createDefaultSecondaryVertices(); + // 修复上一个问题中 GL Context lambda 无法解析 'mesh' 的编译错误 + final Mesh2D mesh = MeshTextureUtil.createQuadForImage(img, name + "_mesh"); part.addMesh(mesh); if (renderPanel != null) { @@ -970,11 +1086,11 @@ public class ModelLayerPanel extends JPanel { } private void createPartWithTransparentTexture() { - String name = JOptionPane.showInputDialog(this, "新图层名称(透明):", "透明图层"); + String name = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "新图层名称(透明):", "透明图层"); if (name == null || name.trim().isEmpty()) return; int w = 128, h = 128; try { - String wh = JOptionPane.showInputDialog(this, "输入尺寸(宽x高,例如 128x128)或留空使用 128x128:", "128x128"); + String wh = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "输入尺寸(宽x高,例如 128x128)或留空使用 128x128:", "128x128"); if (wh != null && wh.contains("x")) { String[] sp = wh.split("x"); w = Math.max(1, Integer.parseInt(sp[0].trim())); @@ -995,6 +1111,8 @@ public class ModelLayerPanel extends JPanel { } model.markNeedsUpdate(); + // 传入名称 + operationManager.addLayer(part.getName()); reloadFromModel(); selectPart(part); thumbnailManager.generateThumbnail(part); @@ -1010,55 +1128,84 @@ public class ModelLayerPanel extends JPanel { // ==================================================================== /** - * 【新增】处理外部文件拖放的 TransferHandler。 - * 支持拖放单个 .psd 或图片文件来创建图层。 + * 【新类】复合拖放处理器:统一处理外部文件拖放和内部图层重排。 + * 将其设置给 JList (layerList),以确保它优先于 JScrollPane 捕获事件。 */ - private class FileDropTransferHandler extends TransferHandler { + private class CompositeLayerTransferHandler extends TransferHandler { + private final LayerReorderTransferHandler internalReorderHandler; private final List IMAGE_EXTENSIONS = List.of("png", "jpg", "jpeg"); private static final String PSD_EXTENSION = "psd"; + public CompositeLayerTransferHandler(ModelLayerPanel panel) { + // 内部图层重排处理器实例 + this.internalReorderHandler = new LayerReorderTransferHandler(panel); + } + + @Override + public int getSourceActions(JComponent c) { + // 委托给内部处理器处理拖出操作 (内部重排) + return internalReorderHandler.getSourceActions(c); + } + + @Override + public Transferable createTransferable(JComponent c) { + // 委托给内部处理器处理拖出数据 (内部重排) + return internalReorderHandler.createTransferable(c); + } + @Override public boolean canImport(TransferSupport support) { - // 检查是否支持文件列表数据格式 - return support.isDataFlavorSupported(DataFlavor.javaFileListFlavor); + // 1. 检查是否为外部文件拖放 (文件列表 DataFlavor) - 优先处理 + if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + return true; + } + + // 2. 否则,委托给内部处理器处理 (图层重排) + return internalReorderHandler.canImport(support); } @Override public boolean importData(TransferSupport support) { - if (!canImport(support)) return false; + // 1. 处理外部文件拖放 + if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + try { + @SuppressWarnings("unchecked") + Transferable t = support.getTransferable(); + List files = (List) t.getTransferData(DataFlavor.javaFileListFlavor); - try { - @SuppressWarnings("unchecked") - List files = (List) support.getTransferable().getTransferData(DataFlavor.javaFileListFlavor); + if (files.size() != 1) { + JOptionPane.showMessageDialog(ModelLayerPanel.this, "仅支持拖放单个文件。", "导入失败", JOptionPane.WARNING_MESSAGE); + return false; + } - if (files.size() != 1) { - // 仅支持拖放单个文件 - JOptionPane.showMessageDialog(ModelLayerPanel.this, "仅支持拖放单个文件。", "导入失败", JOptionPane.WARNING_MESSAGE); + File file = files.get(0); + String fileName = file.getName().toLowerCase(); + String extension = ""; + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex != -1) { + extension = fileName.substring(dotIndex + 1); + } + + if (PSD_EXTENSION.equals(extension)) { + psdImporter.importPSDFile(file); + return true; + } else if (IMAGE_EXTENSIONS.contains(extension)) { + createPartWithTextureFromFile(file); + return true; + } else { + JOptionPane.showMessageDialog(ModelLayerPanel.this, "不支持的文件类型: ." + extension, "导入失败", JOptionPane.WARNING_MESSAGE); + return false; + } + + } catch (Exception ex) { + JOptionPane.showMessageDialog(ModelLayerPanel.this, "文件导入失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); + ex.printStackTrace(); return false; } - - File file = files.get(0); - String fileName = file.getName().toLowerCase(); - String extension = fileName.substring(fileName.lastIndexOf('.') + 1); - - if (PSD_EXTENSION.equals(extension)) { - // 导入 PSD 文件 - psdImporter.importPSDFile(file); - return true; - } else if (IMAGE_EXTENSIONS.contains(extension)) { - // 创建带贴图的图层 - createPartWithTextureFromFile(file); - return true; - } else { - JOptionPane.showMessageDialog(ModelLayerPanel.this, "不支持的文件类型: ." + extension, "导入失败", JOptionPane.WARNING_MESSAGE); - return false; - } - - } catch (Exception ex) { - JOptionPane.showMessageDialog(ModelLayerPanel.this, "文件导入失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); - ex.printStackTrace(); - return false; } + + // 2. 否则,委托给内部处理器处理 (图层重排) + return internalReorderHandler.importData(support); } } @@ -1218,4 +1365,78 @@ public class ModelLayerPanel extends JPanel { return new Dimension(14, 14); } } + + /** + * 【只处理外部文件拖放】的处理器。 + * 将其设置给顶层窗口的内容面板。 + */ + private class FileDropOnlyTransferHandler extends TransferHandler { + private final List IMAGE_EXTENSIONS = List.of("png", "jpg", "jpeg"); + private static final String PSD_EXTENSION = "psd"; + private static final String MODEL_EXTENSION = "model"; + + @Override + public boolean canImport(TransferSupport support) { + return support.isDataFlavorSupported(DataFlavor.javaFileListFlavor); + } + + @Override + public boolean importData(TransferSupport support) { + if (!canImport(support)) { + return false; + } + try { + Transferable t = support.getTransferable(); + @SuppressWarnings("unchecked") + List files = (List) t.getTransferData(DataFlavor.javaFileListFlavor); + if (files.size() != 1) { + JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "仅支持拖放单个文件。", "导入失败", JOptionPane.WARNING_MESSAGE); + return false; + } + final File file = files.get(0); + String fileName = file.getName().toLowerCase(); + String extension = ""; + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex != -1) { + extension = fileName.substring(dotIndex + 1); + } + final String finalExtension = extension; + SwingUtilities.invokeLater(() -> { + if (MODEL_EXTENSION.equals(finalExtension)) { + Window window = SwingUtilities.getWindowAncestor(ModelLayerPanel.this); + if (window instanceof MainWindow mainWindow) { + if (mainWindow.shouldAskUserToSave()) { + int confirm = JOptionPane.showConfirmDialog( + mainWindow, + "当前模型已修改。加载新模型 " + file.getName() + " 前是否保存更改?", + "加载模型确认", + JOptionPane.YES_NO_CANCEL_OPTION + ); + if (confirm == JOptionPane.CANCEL_OPTION) { + return; + } + if (confirm == JOptionPane.YES_OPTION) { + mainWindow.saveData(false); + mainWindow.loadModel(file.getAbsolutePath()); + } + } + } else { + JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "无法获取主窗口引用,无法加载模型文件。", "导入失败", JOptionPane.ERROR_MESSAGE); + } + } else if (PSD_EXTENSION.equals(finalExtension)) { + psdImporter.importPSDFile(file); + } else if (IMAGE_EXTENSIONS.contains(finalExtension)) { + createPartWithTextureFromFile(file); + } else { + JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "不支持的文件类型: ." + finalExtension, "导入失败", JOptionPane.WARNING_MESSAGE); + } + }); + return true; + } catch (Exception ex) { + JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "文件导入失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); + ex.printStackTrace(); + return false; + } + } + } } \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java index d2fc74b..ef9bfaa 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java @@ -387,6 +387,16 @@ public class ModelRenderPanel extends JPanel { final float[][] modelCoords = {worldManagement.screenToModelCoordinates(e.getX(), e.getY())}; + float modelX = modelCoords[0][0]; + float modelY = modelCoords[0][1]; + for (ModelClickListener listener : clickListeners) { + try { + listener.onModelHover(getSelectedMesh(), modelX, modelY, e.getX(), e.getY()); + } catch (Exception ex) { + logger.error("点击事件监听器执行出错", ex); + } + } + // 如果有激活的工具,优先交给工具处理 if (toolManagement.hasActiveTool() && modelCoords[0] != null) { glContextManager.executeInGLContext(() -> toolManagement.handleMouseDragged(e, modelCoords[0][0], modelCoords[0][1])); diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/manager/KeyboardManager.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/manager/KeyboardManager.java index 76638c8..ccdcb35 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/manager/KeyboardManager.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/manager/KeyboardManager.java @@ -94,11 +94,7 @@ public class KeyboardManager { logger.info("{}摄像机", newState ? "启用" : "禁用"); } }); - - // 注册工具快捷键 registerToolShortcuts(); - - // 设置键盘监听器 setupKeyListeners(); } @@ -106,17 +102,6 @@ public class KeyboardManager { * 注册工具快捷键 */ private void registerToolShortcuts() { - // 木偶变形工具快捷键:Ctrl+P - registerShortcut("puppetTool", KeyStroke.getKeyStroke(KeyEvent.VK_P, KeyEvent.CTRL_DOWN_MASK), - new AbstractAction() { - @Override - public void actionPerformed(ActionEvent e) { - panel.switchTool("木偶变形工具"); - logger.info("切换到木偶变形工具"); - } - }); - - // 顶点变形工具快捷键:Ctrl+T registerShortcut("vertexTool", KeyStroke.getKeyStroke(KeyEvent.VK_T, KeyEvent.CTRL_DOWN_MASK), new AbstractAction() { @Override @@ -125,16 +110,6 @@ public class KeyboardManager { logger.info("切换到顶点变形工具"); } }); - - // 选择工具快捷键:Ctrl+S - registerShortcut("selectionTool", KeyStroke.getKeyStroke(KeyEvent.VK_S, KeyEvent.CTRL_DOWN_MASK), - new AbstractAction() { - @Override - public void actionPerformed(ActionEvent e) { - panel.switchTool("选择工具"); - logger.info("切换到选择工具"); - } - }); } /** diff --git a/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java b/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java index 5e515eb..5d4af1d 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import javax.swing.tree.DefaultMutableTreeNode; import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; /** * 2D模型部件,支持层级变换和变形器 @@ -50,7 +51,7 @@ public class ModelPart { private boolean boundsDirty; private boolean pivotInitialized; - private final List events = new ArrayList<>(); + private final List events = new CopyOnWriteArrayList<>(); private boolean inMultiSelectionOperation = false; // ====== 液化模式枚举 ====== diff --git a/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java b/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java index 26aba0c..4e8d520 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java @@ -678,7 +678,7 @@ public class Mesh2D { public boolean setOriginalPivot(Vector2f p) { if (p != null) { - BoundingBox bounds = getBounds(); + BoundingBox bounds = calculateOriginalBounds(); if (bounds != null && p.x >= bounds.getMinX() && p.x <= bounds.getMaxX() && p.y >= bounds.getMinY() && p.y <= bounds.getMaxY()) { diff --git a/src/main/java/com/chuangzhou/vivid2D/window/KeyBindingManager.java b/src/main/java/com/chuangzhou/vivid2D/window/KeyBindingManager.java new file mode 100644 index 0000000..fb9ab19 --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/window/KeyBindingManager.java @@ -0,0 +1,82 @@ +package com.chuangzhou.vivid2D.window; + +import javax.swing.*; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; + +/** + * 负责管理主窗口的全局快捷键。 + */ +public class KeyBindingManager { + + // Action 名称常量 + private static final String ACTION_SAVE = "saveAction"; + private static final String ACTION_SAVE_AS = "saveAsAction"; + + private final JRootPane rootPane; + private final MainWindow mainWindow; + + public KeyBindingManager(MainWindow mainWindow) { + this.mainWindow = mainWindow; + this.rootPane = mainWindow.getRootPane(); + setupGlobalKeyBindings(); + } + + /** + * 设置全局快捷键绑定到 RootPane。 + * 使用 JComponent.WHEN_IN_FOCUSED_WINDOW 确保在窗口获得焦点时生效。 + */ + private void setupGlobalKeyBindings() { + // 获取 InputMap 和 ActionMap + InputMap inputMap = rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); + ActionMap actionMap = rootPane.getActionMap(); + + // 绑定动作 + actionMap.put(ACTION_SAVE, new SaveAction()); + actionMap.put(ACTION_SAVE_AS, new SaveAsAction()); + + // 绑定快捷键 KeyStroke + bindKey(inputMap, KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK, ACTION_SAVE); + bindKey(inputMap, KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK, ACTION_SAVE_AS); + } + + /** + * 辅助方法:简化 KeyStroke 和 Action 的绑定。 + */ + public void bindKey(InputMap inputMap, int keyCode, int modifiers, String actionName) { + KeyStroke key = KeyStroke.getKeyStroke(keyCode, modifiers); + inputMap.put(key, actionName); + } + + /** + * 内部类:Ctrl + S 保存动作。 + */ + private class SaveAction extends AbstractAction { + @Override + public void actionPerformed(ActionEvent e) { + // 调用 MainWindow 的保存方法 (不退出) + mainWindow.saveData(false); + } + } + + /** + * 内部类:Ctrl + Shift + S 另存为动作。 + */ + private class SaveAsAction extends AbstractAction { + @Override + public void actionPerformed(ActionEvent e) { + // 另存为操作:强制进入 "另存为" 逻辑 + String originalPath = mainWindow.currentModelPath; + + // 临时将路径设为 null + mainWindow.currentModelPath = null; + mainWindow.saveData(false); + + // 如果用户取消另存为 (saveData 中 currentModelPath 仍为 null),恢复原来的路径 + if (mainWindow.currentModelPath == null) { + mainWindow.currentModelPath = originalPath; + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/window/MainWindow.java b/src/main/java/com/chuangzhou/vivid2D/window/MainWindow.java index d5cbd1e..784bc7f 100644 --- a/src/main/java/com/chuangzhou/vivid2D/window/MainWindow.java +++ b/src/main/java/com/chuangzhou/vivid2D/window/MainWindow.java @@ -8,12 +8,10 @@ import com.chuangzhou.vivid2D.render.awt.manager.data.ParametersManagementData; import com.chuangzhou.vivid2D.render.model.Model2D; import com.chuangzhou.vivid2D.render.model.ModelPart; import com.chuangzhou.vivid2D.render.model.util.Mesh2D; -import com.formdev.flatlaf.themes.FlatMacDarkLaf; import jnafilechooser.api.JnaFileChooser; import org.jetbrains.annotations.NotNull; import javax.swing.*; -import javax.swing.filechooser.FileNameExtensionFilter; import java.awt.*; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; @@ -37,9 +35,11 @@ public class MainWindow extends JFrame { private final TransformPanel transformPanel; private final ParametersPanel parametersPanel; private final ModelPartInfoPanel partInfoPanel; - private String currentModelPath = null; + private final KeyBindingManager keyBindingManager; + public String currentModelPath = null; private JLabel statusBarLabel; private JMenuBar menuBar; + private boolean isModelModified = false; /** * 构造主窗口。 @@ -61,6 +61,7 @@ public class MainWindow extends JFrame { setupInitialListeners(); setSize(1600, 900); setLocationRelativeTo(null); + keyBindingManager = new KeyBindingManager(this); } /** @@ -118,6 +119,7 @@ public class MainWindow extends JFrame { statusBarLabel.setText("新模型 " + finalModelName + " 创建并加载完毕。"); setEditComponentsEnabled(true); layerPanel.setModel(newModel); + setModelModified(false); } catch (Exception e) { System.err.println("新建模型加载失败: " + e.getMessage()); currentModelPath = null; @@ -215,21 +217,21 @@ public class MainWindow extends JFrame { addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { - if (currentModelPath == null) { - shutdown(); - return; - } - int confirm = JOptionPane.showConfirmDialog( - MainWindow.this, - "是否在退出前保存更改?", - "退出确认", - JOptionPane.YES_NO_CANCEL_OPTION - ); - if (confirm == JOptionPane.CANCEL_OPTION) { - return; - } - if (confirm == JOptionPane.YES_OPTION) { - saveData(true); + if (shouldAskUserToSave()) { + int confirm = JOptionPane.showConfirmDialog( + MainWindow.this, + "模型已修改。是否在退出前保存更改?", + "退出确认", + JOptionPane.YES_NO_CANCEL_OPTION + ); + if (confirm == JOptionPane.CANCEL_OPTION) { + return; + } + if (confirm == JOptionPane.YES_OPTION) { + saveData(true); + } else { + shutdown(); + } } else { shutdown(); } @@ -244,6 +246,7 @@ public class MainWindow extends JFrame { layerPanel.setSelectedLayers(selectedPart); transformPanel.setSelectedParts(selectedPart); if (!selectedPart.isEmpty()) { + setModelModified(true); partInfoPanel.updatePanel(selectedPart.get(0)); } else { partInfoPanel.updatePanel(null); @@ -308,7 +311,7 @@ public class MainWindow extends JFrame { /** * 加载模型并更新 UI 状态。 */ - private void loadModel(String modelPath) { + public void loadModel(String modelPath) { setEditComponentsEnabled(false); statusBarLabel.setText("正在加载模型: " + modelPath); CompletableFuture.runAsync(() -> { @@ -337,6 +340,7 @@ public class MainWindow extends JFrame { statusBarLabel.setText("模型加载完毕。"); setEditComponentsEnabled(true); layerPanel.setModel(finalModel); + setModelModified(false); } }); }); @@ -346,28 +350,16 @@ public class MainWindow extends JFrame { * 保存模型和参数数据。 * @param exitOnComplete 如果为 true,则在保存后调用 shutdown()。 */ - private void saveData(boolean exitOnComplete) { + public void saveData(boolean exitOnComplete) { if (currentModelPath == null) { - JnaFileChooser jnaFileChooser = new JnaFileChooser(); - jnaFileChooser.setTitle("另存为 Vivid2D 模型文件 (*.model)"); - - // JnaFileChooser 使用 addFilter() 来添加过滤器 - jnaFileChooser.addFilter("Vivid2D 模型文件 (*.model)", "model"); - jnaFileChooser.setMultiSelectionEnabled(false); - jnaFileChooser.setOpenButtonText("保存"); - jnaFileChooser.setMode(JnaFileChooser.Mode.Files); - - // 弹出保存对话框 + JnaFileChooser jnaFileChooser = getJnaFileChooser(); if (jnaFileChooser.showSaveDialog(this)) { File fileToSave = jnaFileChooser.getSelectedFile(); String path = fileToSave.getAbsolutePath(); - - // 确保文件以 .model 结尾 (原生对话框可能已经处理,但 Swing 风格代码保留以防万一) if (!path.toLowerCase().endsWith(".model")) { path += ".model"; fileToSave = new File(path); } - this.currentModelPath = path; setTitle("Vivid2D Editor - " + fileToSave.getName()); } else { @@ -393,11 +385,57 @@ public class MainWindow extends JFrame { statusBarLabel.setText("保存参数失败!"); } statusBarLabel.setText("保存成功。"); + setModelModified(false); if (exitOnComplete) { shutdown(); } } + private @NotNull JnaFileChooser getJnaFileChooser() { + JnaFileChooser jnaFileChooser = new JnaFileChooser(); + jnaFileChooser.setTitle("另存为 Vivid2D 模型文件 (*.model)"); + jnaFileChooser.addFilter("Vivid2D 模型文件 (*.model)", "model"); + jnaFileChooser.setMultiSelectionEnabled(false); + jnaFileChooser.setOpenButtonText("保存"); + jnaFileChooser.setMode(JnaFileChooser.Mode.Files); + String defaultFileName; + Model2D currentModel = renderPanel.getModel(); + if (currentModel != null && currentModel.getName() != null && !currentModel.getName().trim().isEmpty()) { + defaultFileName = currentModel.getName() + ".model"; + } else { + defaultFileName = "model.model"; + } + jnaFileChooser.setDefaultFileName(defaultFileName); + return jnaFileChooser; + } + + /** + * 获取模型是否已修改的状态。 + */ + public boolean isModelModified() { + return isModelModified; + } + + /** + * 编辑面板在进行任何修改操作时应调用 setModelModified(true)。 + * 保存或加载成功后应调用 setModelModified(false)。 + */ + public void setModelModified(boolean modified) { + this.isModelModified = modified; + } + + /** + * 判断当前是否需要询问用户保存模型。 + * 只要模型被修改过,就应该询问。 + */ + public boolean shouldAskUserToSave() { + return isModelModified; + } + + public KeyBindingManager getKeyBindingManager() { + return keyBindingManager; + } + /** * 清理资源并退出应用程序。 */