diff --git a/new-shapes.wiki b/new-shapes.wiki new file mode 160000 index 0000000..731a57b --- /dev/null +++ b/new-shapes.wiki @@ -0,0 +1 @@ +Subproject commit 731a57b080f9baa4d03ba3513d642345b410beaa diff --git a/pom.xml b/pom.xml index a5d870a..88702ff 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,12 @@ 1.0-SNAPSHOT + + com.fasterxml.jackson.core + jackson-databind + 2.18.3 + + org.slf4j slf4j-api diff --git a/src/main/java/ovh/gasser/newshapes/App.java b/src/main/java/ovh/gasser/newshapes/App.java index 1ce223d..8674dba 100644 --- a/src/main/java/ovh/gasser/newshapes/App.java +++ b/src/main/java/ovh/gasser/newshapes/App.java @@ -11,6 +11,8 @@ import ovh.gasser.newshapes.ui.listeners.MenuEditListener; import javax.swing.*; import java.awt.*; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; import java.io.FileNotFoundException; public class App { @@ -19,6 +21,8 @@ public class App { private SCollection model; private JCheckBoxMenuItem editFill; private JCheckBoxMenuItem editBorder; + private JMenuItem editGroup; + private JMenuItem editUngroup; private App() throws HeadlessException { final JFrame frame = new JFrame("Reactive shapes"); @@ -71,12 +75,38 @@ public class App { private JMenu buildFileMenu(ShapesView sview) { JMenu menuFile = new JMenu("File"); + JMenuItem openItem = new JMenuItem("Open"); + JMenuItem saveItem = new JMenuItem("Save"); JMenuItem addRectItem = new JMenuItem("Add SRectangle"); JMenuItem addCircleItem = new JMenuItem("Add SCircle"); JMenuItem addTextItem = new JMenuItem("Add Text"); JMenuItem htmlExportItem = new JMenuItem("Export to HTML"); JMenuItem svgExportItem = new JMenuItem("Export to SVG"); JMenuItem exitItem = new JMenuItem("Exit"); + + openItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.CTRL_DOWN_MASK)); + saveItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK)); + + openItem.addActionListener(evt -> { + JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JSON Files", "json")); + if (chooser.showOpenDialog(sview) == JFileChooser.APPROVE_OPTION) { + sview.getController().loadDrawing(chooser.getSelectedFile()); + } + }); + + saveItem.addActionListener(evt -> { + JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JSON Files", "json")); + if (chooser.showSaveDialog(sview) == JFileChooser.APPROVE_OPTION) { + java.io.File file = chooser.getSelectedFile(); + if (!file.getName().endsWith(".json")) { + file = new java.io.File(file.getAbsolutePath() + ".json"); + } + sview.getController().saveDrawing(file); + } + }); + addRectItem.addActionListener(new MenuAddListener("SRectangle", model, sview)); addCircleItem.addActionListener(new MenuAddListener("SCircle", model, sview)); addTextItem.addActionListener(evt -> sview.getController().enterTextMode()); @@ -95,6 +125,10 @@ public class App { } }); exitItem.addActionListener(evt -> System.exit(0)); + + menuFile.add(openItem); + menuFile.add(saveItem); + menuFile.addSeparator(); menuFile.add(addRectItem); menuFile.add(addCircleItem); menuFile.add(addTextItem); @@ -108,32 +142,62 @@ public class App { private JMenu buildEditMenu(ShapesView sview) { MenuEditListener editListener = new MenuEditListener(model, sview, sview.getController()); JMenu menuEdit = new JMenu("Edit"); + JMenuItem cutItem = new JMenuItem("Cut"); + JMenuItem copyItem = new JMenuItem("Copy"); + JMenuItem pasteItem = new JMenuItem("Paste"); JMenuItem editColor = new JMenuItem("Change color"); JMenuItem editBorderColor = new JMenuItem("Change border color"); JMenuItem deleteItem = new JMenuItem("Delete"); + editGroup = new JMenuItem("Group"); + editUngroup = new JMenuItem("Ungroup"); editFill = new JCheckBoxMenuItem("Fill Shape"); editBorder = new JCheckBoxMenuItem("Draw border"); + + cutItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_X, InputEvent.CTRL_DOWN_MASK)); + copyItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK)); + pasteItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_DOWN_MASK)); + editGroup.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_G, InputEvent.CTRL_DOWN_MASK)); + editUngroup.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_G, InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK)); + + cutItem.addActionListener(evt -> sview.getController().cutSelection()); + copyItem.addActionListener(evt -> sview.getController().copySelection()); + pasteItem.addActionListener(evt -> sview.getController().pasteClipboard()); editColor.addActionListener(editListener); editBorderColor.addActionListener(editListener); deleteItem.addActionListener(editListener); + editGroup.addActionListener(evt -> sview.getController().group()); + editUngroup.addActionListener(evt -> sview.getController().ungroup()); editFill.addActionListener(editListener); editBorder.addActionListener(editListener); + editGroup.setEnabled(false); + editUngroup.setEnabled(false); + menuEdit.add(cutItem); + menuEdit.add(copyItem); + menuEdit.add(pasteItem); + menuEdit.addSeparator(); menuEdit.add(editColor); menuEdit.add(editBorderColor); menuEdit.add(deleteItem); menuEdit.addSeparator(); + menuEdit.add(editGroup); + menuEdit.add(editUngroup); + menuEdit.addSeparator(); menuEdit.add(editBorder); menuEdit.add(editFill); return menuEdit; } private void updateMenuState(Iterable selectedShapes) { + int selectionCount = 0; + boolean singleCollectionSelected = false; boolean hasToggleableShapes = false; boolean allFilled = true; boolean allStroked = true; for (Shape s : selectedShapes) { + selectionCount++; + singleCollectionSelected = selectionCount == 1 && s instanceof SCollection; if (s instanceof SText) { continue; } @@ -145,6 +209,8 @@ public class App { } } + editGroup.setEnabled(selectionCount > 1); + editUngroup.setEnabled(selectionCount == 1 && singleCollectionSelected); updateMenuItem(editFill, hasToggleableShapes, allFilled); updateMenuItem(editBorder, hasToggleableShapes, allStroked); } diff --git a/src/main/java/ovh/gasser/newshapes/persistence/DrawingSerializer.java b/src/main/java/ovh/gasser/newshapes/persistence/DrawingSerializer.java new file mode 100644 index 0000000..5305bca --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/persistence/DrawingSerializer.java @@ -0,0 +1,43 @@ +package ovh.gasser.newshapes.persistence; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.Shape; + +import java.io.File; +import java.io.IOException; + +public class DrawingSerializer { + private static final String VERSION = "1.0"; + private final ObjectMapper mapper; + + public DrawingSerializer() { + this.mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule("ShapeModule", new Version(1, 0, 0, null, null, null)); + module.addSerializer(Shape.class, new ShapeSerializer()); + module.addDeserializer(Shape.class, new ShapeDeserializer()); + this.mapper.registerModule(module); + } + + public void save(SCollection model, File file) throws IOException { + DrawingData data = new DrawingData(); + data.version = VERSION; + data.shapes = new java.util.ArrayList<>(); + for (Shape shape : model) { + data.shapes.add(shape); + } + mapper.writerWithDefaultPrettyPrinter().writeValue(file, data); + } + + public SCollection load(File file) throws IOException { + DrawingData data = mapper.readValue(file, DrawingData.class); + return SCollection.of(data.shapes.toArray(new Shape[0])); + } + + public static class DrawingData { + public String version; + public java.util.List shapes; + } +} diff --git a/src/main/java/ovh/gasser/newshapes/persistence/ShapeDeserializer.java b/src/main/java/ovh/gasser/newshapes/persistence/ShapeDeserializer.java new file mode 100644 index 0000000..f627629 --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/persistence/ShapeDeserializer.java @@ -0,0 +1,159 @@ +package ovh.gasser.newshapes.persistence; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import ovh.gasser.newshapes.shapes.SCircle; +import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.SPolygon; +import ovh.gasser.newshapes.shapes.SRectangle; +import ovh.gasser.newshapes.shapes.SText; +import ovh.gasser.newshapes.shapes.STriangle; +import ovh.gasser.newshapes.shapes.Shape; + +import java.awt.*; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class ShapeDeserializer extends StdDeserializer { + + public ShapeDeserializer() { + super(Shape.class); + } + + @Override + public Shape deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + String type = node.get("type").asText(); + + return switch (type) { + case "rectangle" -> deserializeRectangle(node); + case "circle" -> deserializeCircle(node); + case "triangle" -> deserializeTriangle(node); + case "text" -> deserializeText(node); + case "polygon" -> deserializePolygon(node); + case "collection" -> deserializeCollection(node, p, ctxt); + default -> throw new IllegalArgumentException("Unknown shape type: " + type); + }; + } + + private SRectangle deserializeRectangle(JsonNode node) { + int x = node.get("x").asInt(); + int y = node.get("y").asInt(); + int width = node.get("width").asInt(); + int height = node.get("height").asInt(); + + Color strokeColor = extractStrokeColor(node); + return SRectangle.create(x, y, width, height, strokeColor); + } + + private SCircle deserializeCircle(JsonNode node) { + int x = node.get("x").asInt(); + int y = node.get("y").asInt(); + int radius = node.get("radius").asInt(); + + Color strokeColor = extractStrokeColor(node); + Color fillColor = extractFillColor(node); + + SCircle circle = SCircle.create(x + radius, y + radius, radius); + circle.addAttributes(new ovh.gasser.newshapes.attributes.ColorAttributes( + fillColor != null, strokeColor != null, strokeColor, fillColor)); + return circle; + } + + private STriangle deserializeTriangle(JsonNode node) { + int x = node.get("x").asInt(); + int y = node.get("y").asInt(); + int size = node.get("size").asInt(); + + Color strokeColor = extractStrokeColor(node); + Color fillColor = extractFillColor(node); + + return STriangle.create(x, y, size, fillColor != null ? fillColor : Color.YELLOW, + strokeColor != null ? strokeColor : Color.BLACK); + } + + private SText deserializeText(JsonNode node) { + int x = node.get("x").asInt(); + int y = node.get("y").asInt(); + String text = node.get("text").asText(); + + Color strokeColor = extractStrokeColor(node); + + SText sText = SText.create(x, y, text); + if (strokeColor != null) { + sText.addAttributes(new ovh.gasser.newshapes.attributes.ColorAttributes( + true, true, strokeColor, strokeColor)); + } + return sText; + } + + private SPolygon deserializePolygon(JsonNode node) { + List points = new ArrayList<>(); + JsonNode pointsNode = node.get("points"); + for (JsonNode pointNode : pointsNode) { + int x = pointNode.get("x").asInt(); + int y = pointNode.get("y").asInt(); + points.add(new Point(x, y)); + } + + Color strokeColor = extractStrokeColor(node); + Color fillColor = extractFillColor(node); + + SPolygon poly = SPolygon.create(points); + if (strokeColor != null || fillColor != null) { + poly.addAttributes(new ovh.gasser.newshapes.attributes.ColorAttributes( + fillColor != null, strokeColor != null, + strokeColor != null ? strokeColor : Color.BLACK, + fillColor != null ? fillColor : Color.BLACK)); + } + return poly; + } + + private SCollection deserializeCollection(JsonNode node, JsonParser p, DeserializationContext ctxt) throws IOException { + List children = new ArrayList<>(); + JsonNode shapesNode = node.get("shapes"); + if (shapesNode != null && shapesNode.isArray()) { + for (JsonNode childNode : shapesNode) { + JsonParser childParser = childNode.traverse(p.getCodec()); + children.add(deserialize(childParser, ctxt)); + } + } + return SCollection.of(children.toArray(new Shape[0])); + } + + private Color extractStrokeColor(JsonNode node) { + if (node.has("color")) { + JsonNode colorNode = node.get("color"); + if (colorNode.has("stroked") && colorNode.get("stroked").asBoolean()) { + return hexToColor(colorNode.get("strokedColor").asText()); + } + } + return null; + } + + private Color extractFillColor(JsonNode node) { + if (node.has("color")) { + JsonNode colorNode = node.get("color"); + if (colorNode.has("filled") && colorNode.get("filled").asBoolean()) { + return hexToColor(colorNode.get("filledColor").asText()); + } + } + return null; + } + + private Color hexToColor(String hex) { + if (hex == null || hex.isEmpty()) return Color.BLACK; + try { + return new Color( + Integer.parseInt(hex.substring(1, 3), 16), + Integer.parseInt(hex.substring(3, 5), 16), + Integer.parseInt(hex.substring(5, 7), 16) + ); + } catch (Exception e) { + return Color.BLACK; + } + } +} diff --git a/src/main/java/ovh/gasser/newshapes/persistence/ShapeSerializer.java b/src/main/java/ovh/gasser/newshapes/persistence/ShapeSerializer.java new file mode 100644 index 0000000..8a7961d --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/persistence/ShapeSerializer.java @@ -0,0 +1,121 @@ +package ovh.gasser.newshapes.persistence; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import ovh.gasser.newshapes.attributes.ColorAttributes; +import ovh.gasser.newshapes.shapes.SCircle; +import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.SPolygon; +import ovh.gasser.newshapes.shapes.SRectangle; +import ovh.gasser.newshapes.shapes.SText; +import ovh.gasser.newshapes.shapes.STriangle; +import ovh.gasser.newshapes.shapes.Shape; + +import java.awt.*; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class ShapeSerializer extends JsonSerializer { + + @Override + public void serialize(Shape shape, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + + if (shape instanceof SRectangle rect) { + gen.writeStringField("type", "rectangle"); + writeRectangle(rect, gen); + } else if (shape instanceof SCircle circle) { + gen.writeStringField("type", "circle"); + writeCircle(circle, gen); + } else if (shape instanceof STriangle tri) { + gen.writeStringField("type", "triangle"); + writeTriangle(tri, gen); + } else if (shape instanceof SText text) { + gen.writeStringField("type", "text"); + writeText(text, gen); + } else if (shape instanceof SPolygon poly) { + gen.writeStringField("type", "polygon"); + writePolygon(poly, gen); + } else if (shape instanceof SCollection coll) { + gen.writeStringField("type", "collection"); + writeCollection(coll, gen); + } + + gen.writeEndObject(); + } + + private void writeRectangle(SRectangle rect, JsonGenerator gen) throws IOException { + Rectangle bounds = rect.getBounds(); + gen.writeNumberField("x", bounds.x); + gen.writeNumberField("y", bounds.y); + gen.writeNumberField("width", bounds.width); + gen.writeNumberField("height", bounds.height); + writeColorAttributes(rect, gen); + } + + private void writeCircle(SCircle circle, JsonGenerator gen) throws IOException { + Rectangle bounds = circle.getBounds(); + gen.writeNumberField("x", bounds.x); + gen.writeNumberField("y", bounds.y); + gen.writeNumberField("radius", circle.getRadius()); + writeColorAttributes(circle, gen); + } + + private void writeTriangle(STriangle tri, JsonGenerator gen) throws IOException { + Rectangle bounds = tri.getBounds(); + gen.writeNumberField("x", bounds.x); + gen.writeNumberField("y", bounds.y); + gen.writeNumberField("size", bounds.width); + writeColorAttributes(tri, gen); + } + + private void writeText(SText text, JsonGenerator gen) throws IOException { + Rectangle bounds = text.getBounds(); + gen.writeNumberField("x", bounds.x); + gen.writeNumberField("y", bounds.y); + gen.writeStringField("text", text.getText()); + gen.writeStringField("fontName", text.getFontName()); + gen.writeNumberField("fontSize", text.getFontSize()); + gen.writeNumberField("fontStyle", text.getFontStyle()); + writeColorAttributes(text, gen); + } + + private void writePolygon(SPolygon poly, JsonGenerator gen) throws IOException { + gen.writeArrayFieldStart("points"); + for (Point p : poly.getPoints()) { + gen.writeStartObject(); + gen.writeNumberField("x", p.x); + gen.writeNumberField("y", p.y); + gen.writeEndObject(); + } + gen.writeEndArray(); + writeColorAttributes(poly, gen); + } + + private void writeCollection(SCollection coll, JsonGenerator gen) throws IOException { + gen.writeArrayFieldStart("shapes"); + for (Shape child : coll) { + serialize(child, gen, null); + } + gen.writeEndArray(); + } + + private void writeColorAttributes(Shape shape, JsonGenerator gen) throws IOException { + ColorAttributes attrs = (ColorAttributes) shape.getAttributes(ColorAttributes.ID); + if (attrs != null) { + gen.writeObjectFieldStart("color"); + gen.writeBooleanField("filled", attrs.filled); + gen.writeBooleanField("stroked", attrs.stroked); + gen.writeStringField("filledColor", colorToHex(attrs.filledColor)); + gen.writeStringField("strokedColor", colorToHex(attrs.strokedColor)); + gen.writeEndObject(); + } + } + + private String colorToHex(Color c) { + if (c == null) return "#000000"; + return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue()); + } +} diff --git a/src/main/java/ovh/gasser/newshapes/ui/Controller.java b/src/main/java/ovh/gasser/newshapes/ui/Controller.java index f1b2205..a3fbb7f 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/Controller.java +++ b/src/main/java/ovh/gasser/newshapes/ui/Controller.java @@ -5,6 +5,15 @@ import org.slf4j.LoggerFactory; import ovh.gasser.newshapes.HTMLExporter; import ovh.gasser.newshapes.Selection; import ovh.gasser.newshapes.attributes.ColorAttributes; +import ovh.gasser.newshapes.persistence.DrawingSerializer; +import ovh.gasser.newshapes.command.AddShapeCommand; +import ovh.gasser.newshapes.command.ChangeColorCommand; +import ovh.gasser.newshapes.command.Command; +import ovh.gasser.newshapes.command.CommandHistory; +import ovh.gasser.newshapes.command.CommandHistoryListener; +import ovh.gasser.newshapes.command.MoveShapeCommand; +import ovh.gasser.newshapes.command.RemoveShapeCommand; +import ovh.gasser.newshapes.command.ResizeShapeCommand; import ovh.gasser.newshapes.shapes.ResizeHandle; import ovh.gasser.newshapes.shapes.SCollection; import ovh.gasser.newshapes.shapes.SText; @@ -13,18 +22,28 @@ import ovh.gasser.newshapes.ui.listeners.SelectionListener; import javax.swing.*; import java.awt.*; +import java.awt.event.InputEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; public class Controller { - private final static Logger logger = LoggerFactory.getLogger(Controller.class); + private static final Logger logger = LoggerFactory.getLogger(Controller.class); + private final ShapesView view; private final SCollection model; private final Selection selection; + private final CommandHistory commandHistory; private Point lastMousePos; private boolean addingText; @@ -35,11 +54,24 @@ public class Controller { private Point selectionBoxStart; private Point selectionBoxEnd; private boolean boxSelecting; + private Point dragStartMousePos; + private Map dragStartBounds = Map.of(); + private final Runnable onModelChanged; + private java.util.List clipboard = java.util.List.of(); + private int pasteCount = 0; + private static final int PASTE_OFFSET = 20; + private boolean additiveBoxSelection; Controller(ShapesView view, SCollection model) { + this(view, model, () -> { }); + } + + Controller(ShapesView view, SCollection model, Runnable onModelChanged) { this.view = view; this.model = model; + this.onModelChanged = onModelChanged; this.selection = new Selection(); + this.commandHistory = new CommandHistory(); var adapter = new MouseAdapter() { @Override @@ -54,14 +86,7 @@ public class Controller { @Override public void mouseReleased(MouseEvent evt) { - resizing = false; - activeHandle = null; - resizeOrigin = null; - boxSelecting = false; - selectionBoxStart = null; - selectionBoxEnd = null; - view.setCurrentSelectionBox(null); - view.repaint(); + handleMouseReleased(evt); } }; this.view.addMouseMotionListener(adapter); @@ -74,6 +99,249 @@ public class Controller { }); } + public void addShape(Shape shape) { + executeAndRefresh(new AddShapeCommand(model, shape)); + } + + public void addSelectionChangeListener(SelectionListener listener) { + selection.addListener(listener); + } + + public void addHistoryChangeListener(CommandHistoryListener listener) { + commandHistory.addListener(listener); + } + + public boolean canUndo() { + return commandHistory.canUndo(); + } + + public boolean canRedo() { + return commandHistory.canRedo(); + } + + public void undo() { + if (!commandHistory.canUndo()) { + return; + } + + resetSelection(); + commandHistory.undo(); + view.repaint(); + } + + public void redo() { + if (!commandHistory.canRedo()) { + return; + } + + resetSelection(); + commandHistory.redo(); + view.repaint(); + } + + public void saveDrawing(java.io.File file) { + try { + new DrawingSerializer().save(model, file); + logger.info("Saved drawing to {}", file.getAbsolutePath()); + } catch (java.io.IOException e) { + logger.error("Failed to save drawing: {}", e.getMessage()); + JOptionPane.showMessageDialog(view, "Failed to save: " + e.getMessage(), "Save Error", JOptionPane.ERROR_MESSAGE); + } + } + + public void loadDrawing(java.io.File file) { + try { + SCollection loaded = new DrawingSerializer().load(file); + model.stream().toList().forEach(model::remove); + loaded.forEach(model::add); + resetSelection(); + view.repaint(); + logger.info("Loaded drawing from {}", file.getAbsolutePath()); + } catch (java.io.IOException e) { + logger.error("Failed to load drawing: {}", e.getMessage()); + JOptionPane.showMessageDialog(view, "Failed to load: " + e.getMessage(), "Load Error", JOptionPane.ERROR_MESSAGE); + } + } + + public void enterTextMode() { + addingText = true; + } + + public void deleteSelected() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + return; + } + + logger.debug("Deleting selected shape(s)"); + executeAndRefresh(new RemoveShapeCommand(model, selectedShapes)); + resetSelection(); + } + + public void copySelection() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + logger.debug("No selection to copy"); + return; + } + + clipboard = selectedShapes.stream() + .map(Shape::clone) + .toList(); + pasteCount = 0; + } + + public void cutSelection() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + logger.debug("No selection to cut"); + return; + } + + clipboard = selectedShapes.stream() + .map(Shape::clone) + .toList(); + pasteCount = 0; + + for (Shape shape : selectedShapes) { + model.remove(shape); + } + resetSelection(); + onModelChanged.run(); + view.repaint(); + } + + public void pasteClipboard() { + if (clipboard.isEmpty()) { + logger.debug("Clipboard is empty"); + return; + } + + pasteCount++; + int offset = PASTE_OFFSET * pasteCount; + + resetSelection(); + + for (Shape original : clipboard) { + Shape clone = original.clone(); + clone.translate(offset, offset); + model.add(clone); + selection.add(clone); + } + + onModelChanged.run(); + view.repaint(); + } + + public void group() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.size() < 2) { + logger.debug("Need at least 2 shapes to group"); + return; + } + + int minIndex = selectedShapes.stream() + .mapToInt(model::indexOf) + .min() + .orElse(0); + + for (Shape shape : selectedShapes) { + model.remove(shape); + shape.addAttributes(new ovh.gasser.newshapes.attributes.SelectionAttributes(false)); + } + + Shape[] shapesArray = selectedShapes.toArray(new Shape[0]); + SCollection group = SCollection.of(shapesArray); + + model.insert(minIndex, group); + resetSelection(); + selection.add(group); + onModelChanged.run(); + view.repaint(); + } + + public void ungroup() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.size() != 1) { + logger.debug("Can only ungroup a single selected group"); + return; + } + + Shape selected = selectedShapes.get(0); + if (!(selected instanceof SCollection group)) { + logger.debug("Selected shape is not a group"); + return; + } + + List children = new ArrayList<>(); + group.forEach(children::add); + + model.remove(group); + + for (Shape child : children) { + model.add(child); + } + + resetSelection(); + for (Shape child : children) { + selection.add(child); + } + onModelChanged.run(); + view.repaint(); + } + + public void changeSelectionColor() { + changeColors(shape -> new ColorAttributes(false, true, Color.BLACK, randomColor())); + } + + public void changeSelectionFillColor(Color filledColor) { + if (filledColor == null) { + return; + } + + changeColors(shape -> { + ColorAttributes current = currentColor(shape); + if (shape instanceof SText) { + return new ColorAttributes(current.filled, current.stroked, filledColor, filledColor); + } + return new ColorAttributes(true, current.stroked, filledColor, current.strokedColor); + }); + } + + public void changeSelectionBorderColor(Color strokedColor) { + if (strokedColor == null) { + return; + } + + changeColors(shape -> { + ColorAttributes current = currentColor(shape); + if (shape instanceof SText) { + return new ColorAttributes(current.filled, current.stroked, strokedColor, strokedColor); + } + return new ColorAttributes(current.filled, true, current.filledColor, strokedColor); + }); + } + + public void setSelectionBorder(boolean state) { + changeColors(shape -> { + if (shape instanceof SText) { + return null; + } + ColorAttributes current = currentColor(shape); + return new ColorAttributes(current.filled, state, current.filledColor, current.strokedColor); + }); + } + + public void setSelectionFilled(boolean state) { + changeColors(shape -> { + if (shape instanceof SText) { + return null; + } + ColorAttributes current = currentColor(shape); + return new ColorAttributes(state, current.stroked, current.filledColor, current.strokedColor); + }); + } + private void handleMouseDragged(MouseEvent evt) { if (boxSelecting) { selectionBoxEnd = evt.getPoint(); @@ -84,35 +352,19 @@ public class Controller { return; } - boxSelecting = false; - if (resizeMode && resizing && activeHandle != null) { - logger.debug("RESIZING with handle {} at ({}, {})", activeHandle, evt.getX(), evt.getY()); int dx = evt.getX() - resizeOrigin.x; int dy = evt.getY() - resizeOrigin.y; - logger.debug("About to resize {} shapes, dx={}, dy={}", selection.getSelectedShapes().size(), dx, dy); for (Shape shape : selection) { - logger.debug("Calling resize on shape: {}", shape); shape.resize(activeHandle, dx, dy); } resizeOrigin = evt.getPoint(); - } else if (resizeMode && !selection.isEmpty()) { - lastMousePos = evt.getPoint(); - ResizeHandle handle = getHandleAt(evt.getPoint()); - if (handle != null) { - resizing = true; - activeHandle = handle; - resizeOrigin = evt.getPoint(); - } - } else { - resizing = false; - activeHandle = null; + } else if (!selection.isEmpty()) { if (lastMousePos == null) { lastMousePos = evt.getPoint(); } int dx = evt.getX() - lastMousePos.x; int dy = evt.getY() - lastMousePos.y; - logger.debug("MOVING shapes with delta ({}, {})", dx, dy); for (Shape shape : selection) { shape.translate(dx, dy); } @@ -127,18 +379,17 @@ public class Controller { return; } - resizing = false; - activeHandle = null; - resizeOrigin = null; - boxSelecting = false; // Clear box selection mode when clicking + resizeDragState(); + boxSelecting = false; + view.setCurrentSelectionBox(null); if (resizeMode && !selection.isEmpty()) { ResizeHandle handle = getHandleAt(evt.getPoint()); - logger.debug("In resize mode, checking handle at ({}, {}): {}", evt.getX(), evt.getY(), handle); if (handle != null) { resizing = true; activeHandle = handle; resizeOrigin = evt.getPoint(); + beginDrag(evt.getPoint()); view.repaint(); return; } @@ -151,96 +402,86 @@ public class Controller { resetSelection(); } if (!selection.getSelectedShapes().contains(s)) { - lastMousePos = evt.getPoint(); selection.add(s); - logger.debug("Selecting {}", s); } + beginDrag(evt.getPoint()); }, () -> { - // Clicked on empty space - start box selection - resetSelection(); + additiveBoxSelection = evt.isShiftDown(); + if (!additiveBoxSelection) { + resetSelection(); + } selectionBoxStart = evt.getPoint(); + selectionBoxEnd = evt.getPoint(); boxSelecting = true; + dragStartMousePos = null; + dragStartBounds = Map.of(); } ); } - private void placeTextAt(Point point) { - String input = JOptionPane.showInputDialog(view, "Enter text:", "Add text", JOptionPane.PLAIN_MESSAGE); - addingText = false; - if (input == null) { - return; - } - - model.add(SText.create(point.x, point.y, input)); - resetSelection(); + private void handleMouseReleased(MouseEvent evt) { + finishDragCommand(); + resizeDragState(); + boxSelecting = false; + selectionBoxStart = null; + selectionBoxEnd = null; + dragStartMousePos = null; + dragStartBounds = Map.of(); + view.setCurrentSelectionBox(null); view.repaint(); } - public void enterTextMode() { - addingText = true; - } + private void handleKeyPressed(KeyEvent evt) { + int modifiers = evt.getModifiersEx(); - private ResizeHandle getHandleAt(Point point) { - final int handleSize = 10; - for (Shape shape : selection) { - Rectangle bounds = shape.getBounds(); - - // Always-present SE corner handle - if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && - point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { - return ResizeHandle.SE; - } - - // Resize handles are only active in resize mode - if (resizeMode) { - // NW corner - if (point.x >= bounds.x - handleSize && point.x <= bounds.x && - point.y >= bounds.y - handleSize && point.y <= bounds.y) { - return ResizeHandle.NW; + if ((modifiers & InputEvent.CTRL_DOWN_MASK) != 0) { + switch (evt.getKeyCode()) { + case KeyEvent.VK_Z -> { + if ((modifiers & InputEvent.SHIFT_DOWN_MASK) != 0) { + redo(); + } else { + undo(); + } + evt.consume(); + return; } - // NE corner - if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && - point.y >= bounds.y - handleSize && point.y <= bounds.y) { - return ResizeHandle.NE; + case KeyEvent.VK_Y -> { + redo(); + evt.consume(); + return; } - // SW corner - if (point.x >= bounds.x - handleSize && point.x <= bounds.x && - point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { - return ResizeHandle.SW; + case KeyEvent.VK_C -> { + copySelection(); + evt.consume(); + return; } - // N edge - int edgeX = bounds.x + bounds.width / 2 - handleSize / 2; - if (point.x >= edgeX && point.x <= edgeX + handleSize && - point.y >= bounds.y - handleSize && point.y <= bounds.y) { - return ResizeHandle.N; + case KeyEvent.VK_X -> { + cutSelection(); + evt.consume(); + return; } - // S edge - if (point.x >= edgeX && point.x <= edgeX + handleSize && - point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { - return ResizeHandle.S; + case KeyEvent.VK_V -> { + pasteClipboard(); + evt.consume(); + return; } - // W edge - int edgeY = bounds.y + bounds.height / 2 - handleSize / 2; - if (point.x >= bounds.x - handleSize && point.x <= bounds.x && - point.y >= edgeY && point.y <= edgeY + handleSize) { - return ResizeHandle.W; + case KeyEvent.VK_G -> { + group(); + evt.consume(); + return; } - // E edge - if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && - point.y >= edgeY && point.y <= edgeY + handleSize) { - return ResizeHandle.E; + case KeyEvent.VK_U -> { + ungroup(); + evt.consume(); + return; } } } - return null; - } - private void handleKeyPressed(KeyEvent evt) { switch (evt.getKeyCode()) { case KeyEvent.VK_R -> toggleResizeMode(); case KeyEvent.VK_DELETE -> deleteSelected(); - case KeyEvent.VK_C -> copySelection(); case KeyEvent.VK_A -> changeSelectionColor(); case KeyEvent.VK_H -> exportHtml(); default -> logger.warn("Pressed unhandled key: {}", evt.getKeyChar()); @@ -264,55 +505,134 @@ public class Controller { } } - private void changeSelectionColor(){ - if (selection == null) { - logger.debug("No selection to change color of"); + private void placeTextAt(Point point) { + String input = JOptionPane.showInputDialog(view, "Enter text:", "Add text", JOptionPane.PLAIN_MESSAGE); + addingText = false; + if (input == null) { return; } - for (Shape s : selection) { - if (s instanceof SCollection collection) { - collection.forEach(shape -> shape.addAttributes(new ColorAttributes(false, true, Color.BLACK, new Color((int) (Math.random() * 0x1000000))))); - } else { - s.addAttributes(new ColorAttributes(false, true, Color.BLACK, new Color((int) (Math.random() * 0x1000000)))); + addShape(SText.create(point.x, point.y, input)); + resetSelection(); + } + + private ResizeHandle getHandleAt(Point point) { + final int handleSize = 10; + for (Shape shape : selection) { + Rectangle bounds = shape.getBounds(); + + if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && + point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { + return ResizeHandle.SE; + } + + if (resizeMode) { + if (point.x >= bounds.x - handleSize && point.x <= bounds.x && + point.y >= bounds.y - handleSize && point.y <= bounds.y) { + return ResizeHandle.NW; + } + if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && + point.y >= bounds.y - handleSize && point.y <= bounds.y) { + return ResizeHandle.NE; + } + if (point.x >= bounds.x - handleSize && point.x <= bounds.x && + point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { + return ResizeHandle.SW; + } + int edgeX = bounds.x + bounds.width / 2 - handleSize / 2; + if (point.x >= edgeX && point.x <= edgeX + handleSize && + point.y >= bounds.y - handleSize && point.y <= bounds.y) { + return ResizeHandle.N; + } + if (point.x >= edgeX && point.x <= edgeX + handleSize && + point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { + return ResizeHandle.S; + } + int edgeY = bounds.y + bounds.height / 2 - handleSize / 2; + if (point.x >= bounds.x - handleSize && point.x <= bounds.x && + point.y >= edgeY && point.y <= edgeY + handleSize) { + return ResizeHandle.W; + } + if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && + point.y >= edgeY && point.y <= edgeY + handleSize) { + return ResizeHandle.E; + } } } - - view.repaint(); + return null; } - private void copySelection() { - if (selection == null) { - logger.debug("No selection to copy"); + private void changeColors(Function updater) { + Map before = new LinkedHashMap<>(); + Map after = new LinkedHashMap<>(); + List targets = flattenSelection(selection.getSelectedShapes()); + + for (Shape shape : targets) { + ColorAttributes current = currentColor(shape); + if (current == null) { + continue; + } + + ColorAttributes updated = updater.apply(shape); + if (updated == null || sameColor(current, updated)) { + continue; + } + + before.put(shape, copy(current)); + after.put(shape, copy(updated)); + } + + if (before.isEmpty()) { return; } - for (Shape shape : selection) { - this.model.add(shape.clone()); - } - - view.repaint(); + executeAndRefresh(new ChangeColorCommand(before.keySet(), before, after)); } - public void deleteSelected() { - if (selection == null) return; - logger.debug("Deleting selected shape(s)"); - for (Shape s : selection) { - this.model.remove(s); + private void beginDrag(Point point) { + lastMousePos = point; + dragStartMousePos = point; + dragStartBounds = captureBounds(selection.getSelectedShapes()); + } + + private void finishDragCommand() { + if (boxSelecting || dragStartBounds.isEmpty()) { + return; } - resetSelection(); + + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + return; + } + + if (resizing && boundsChanged(selectedShapes, dragStartBounds)) { + Map afterBounds = captureBounds(selectedShapes); + Command command = new ResizeShapeCommand(selectedShapes, dragStartBounds, afterBounds); + command.undo(); + executeAndRefresh(command); + return; + } + + if (boundsChanged(selectedShapes, dragStartBounds)) { + Shape reference = selectedShapes.get(0); + Rectangle before = dragStartBounds.get(reference); + Rectangle after = reference.getBounds(); + Command command = new MoveShapeCommand(selectedShapes, after.x - before.x, after.y - before.y); + command.undo(); + executeAndRefresh(command); + } + } + + private void executeAndRefresh(Command command) { + commandHistory.execute(command); + onModelChanged.run(); view.repaint(); } private void resetSelection() { - logger.debug("Resetting selection"); selection.clear(); } - public void addSelectionChangeListener(SelectionListener listener) { - selection.addListener(listener); - } - private Optional getTarget(MouseEvent evt, SCollection sc) { return sc.stream() .filter(s -> s.getBounds().contains(evt.getPoint())) @@ -328,9 +648,71 @@ public class Controller { } private void updateSelectionFromBox(Rectangle box) { - resetSelection(); + if (!additiveBoxSelection) { + resetSelection(); + } model.stream() .filter(s -> s.getBounds().intersects(box)) .forEach(selection::add); } + + private void resizeDragState() { + resizing = false; + activeHandle = null; + resizeOrigin = null; + lastMousePos = null; + } + + private Map captureBounds(Collection shapes) { + Map bounds = new LinkedHashMap<>(); + for (Shape shape : shapes) { + bounds.put(shape, shape.getBounds()); + } + return bounds; + } + + private boolean boundsChanged(Collection shapes, Map before) { + for (Shape shape : shapes) { + Rectangle previous = before.get(shape); + if (previous != null && !previous.equals(shape.getBounds())) { + return true; + } + } + return false; + } + + private List flattenSelection(Collection selectedShapes) { + LinkedHashSet flattened = new LinkedHashSet<>(); + for (Shape shape : selectedShapes) { + flattenShape(shape, flattened); + } + return new ArrayList<>(flattened); + } + + private void flattenShape(Shape shape, Collection flattened) { + if (shape instanceof SCollection collection) { + collection.forEach(child -> flattenShape(child, flattened)); + return; + } + flattened.add(shape); + } + + private ColorAttributes currentColor(Shape shape) { + return (ColorAttributes) shape.getAttributes(ColorAttributes.ID); + } + + private ColorAttributes copy(ColorAttributes attrs) { + return new ColorAttributes(attrs.filled, attrs.stroked, attrs.filledColor, attrs.strokedColor); + } + + private boolean sameColor(ColorAttributes left, ColorAttributes right) { + return left.filled == right.filled + && left.stroked == right.stroked + && left.filledColor.equals(right.filledColor) + && left.strokedColor.equals(right.strokedColor); + } + + private Color randomColor() { + return new Color((int) (Math.random() * 0x1000000)); + } }