From f8a6d786eef36f23983ff96a9e40a97c1797736d Mon Sep 17 00:00:00 2001 From: Thibaud Date: Thu, 19 Mar 2026 21:45:05 +0100 Subject: [PATCH] feat: implement SText shape --- TODO.md | 5 +- src/main/java/ovh/gasser/newshapes/App.java | 22 +++--- .../ovh/gasser/newshapes/ShapeVisitor.java | 3 + .../ovh/gasser/newshapes/shapes/SText.java | 79 +++++++++++++++++++ .../ovh/gasser/newshapes/ui/Controller.java | 24 ++++++ .../gasser/newshapes/ui/ShapeDraftman.java | 36 +++++++++ .../ui/listeners/MenuEditListener.java | 11 +++ .../newshapes/ui/visitors/HTMLDraftman.java | 41 +++++++++- .../newshapes/ui/visitors/SVGDraftman.java | 34 ++++++++ style.css | 27 +++++-- 10 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 src/main/java/ovh/gasser/newshapes/shapes/SText.java diff --git a/TODO.md b/TODO.md index 19dcdfa..0ddad01 100644 --- a/TODO.md +++ b/TODO.md @@ -1,11 +1,8 @@ # TODO -![](out.svg) - - [ ] Box selection (drag to select multiple shapes) - [ ] Undo/redo stack - [ ] Copy/paste functionality -- [ ] Text shapes +- [X] Text shapes - [ ] Resize shapes - [ ] Polygon shapes - diff --git a/src/main/java/ovh/gasser/newshapes/App.java b/src/main/java/ovh/gasser/newshapes/App.java index f66788e..1ce223d 100644 --- a/src/main/java/ovh/gasser/newshapes/App.java +++ b/src/main/java/ovh/gasser/newshapes/App.java @@ -8,7 +8,6 @@ import ovh.gasser.newshapes.shapes.Shape; import ovh.gasser.newshapes.ui.ShapesView; import ovh.gasser.newshapes.ui.listeners.MenuAddListener; import ovh.gasser.newshapes.ui.listeners.MenuEditListener; -import ovh.gasser.newshapes.ui.listeners.SelectionListener; import javax.swing.*; import java.awt.*; @@ -39,12 +38,7 @@ public class App { this.buildMenuBar(frame, view); - view.addSelectionChangeListener(new SelectionListener() { - @Override - public void onSelectionChanged(Iterable selectedShapes) { - updateMenuState(selectedShapes); - } - }); + view.addSelectionChangeListener(this::updateMenuState); } private void buildModel() { @@ -79,11 +73,13 @@ public class App { JMenu menuFile = new JMenu("File"); 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"); addRectItem.addActionListener(new MenuAddListener("SRectangle", model, sview)); addCircleItem.addActionListener(new MenuAddListener("SCircle", model, sview)); + addTextItem.addActionListener(evt -> sview.getController().enterTextMode()); htmlExportItem.addActionListener(evt -> { try { new HTMLExporter(model).export(); @@ -101,6 +97,7 @@ public class App { exitItem.addActionListener(evt -> System.exit(0)); menuFile.add(addRectItem); menuFile.add(addCircleItem); + menuFile.add(addTextItem); menuFile.addSeparator(); menuFile.add(htmlExportItem); menuFile.add(svgExportItem); @@ -132,21 +129,24 @@ public class App { } private void updateMenuState(Iterable selectedShapes) { - boolean hasAttributes = false; + boolean hasToggleableShapes = false; boolean allFilled = true; boolean allStroked = true; for (Shape s : selectedShapes) { + if (s instanceof SText) { + continue; + } ColorAttributes attrs = (ColorAttributes) s.getAttributes(ColorAttributes.ID); if (attrs != null) { - hasAttributes = true; + hasToggleableShapes = true; allFilled = allFilled && attrs.filled; allStroked = allStroked && attrs.stroked; } } - updateMenuItem(editFill, hasAttributes, allFilled); - updateMenuItem(editBorder, hasAttributes, allStroked); + updateMenuItem(editFill, hasToggleableShapes, allFilled); + updateMenuItem(editBorder, hasToggleableShapes, allStroked); } private void updateMenuItem(JCheckBoxMenuItem menuItem, boolean hasAttributes, boolean allSelected) { diff --git a/src/main/java/ovh/gasser/newshapes/ShapeVisitor.java b/src/main/java/ovh/gasser/newshapes/ShapeVisitor.java index c2024b9..2362f52 100644 --- a/src/main/java/ovh/gasser/newshapes/ShapeVisitor.java +++ b/src/main/java/ovh/gasser/newshapes/ShapeVisitor.java @@ -3,6 +3,7 @@ package ovh.gasser.newshapes; import ovh.gasser.newshapes.shapes.SCircle; import ovh.gasser.newshapes.shapes.SCollection; import ovh.gasser.newshapes.shapes.SRectangle; +import ovh.gasser.newshapes.shapes.SText; import ovh.gasser.newshapes.shapes.STriangle; public interface ShapeVisitor { @@ -13,4 +14,6 @@ public interface ShapeVisitor { void visitCircle(SCircle sCircle); void visitTriangle(STriangle sTriangle); + + void visitText(SText sText); } diff --git a/src/main/java/ovh/gasser/newshapes/shapes/SText.java b/src/main/java/ovh/gasser/newshapes/shapes/SText.java new file mode 100644 index 0000000..bdcadff --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/shapes/SText.java @@ -0,0 +1,79 @@ +package ovh.gasser.newshapes.shapes; + +import ovh.gasser.newshapes.ShapeVisitor; +import ovh.gasser.newshapes.attributes.ColorAttributes; +import ovh.gasser.newshapes.attributes.SelectionAttributes; + +import java.awt.*; + +public class SText extends AbstractShape { + public static final String PLACEHOLDER_TEXT = "Text"; + public static final int DEFAULT_FONT_SIZE = 16; + public static final String DEFAULT_FONT_NAME = "SansSerif"; + public static final int DEFAULT_FONT_STYLE = Font.PLAIN; + + private final String text; + private final int fontSize; + private final String fontName; + private final int fontStyle; + + private SText(int x, int y, String text, int fontSize, String fontName, int fontStyle) { + super(new Rectangle(x, y, 0, 0)); + this.text = normalizeText(text); + this.fontSize = fontSize; + this.fontName = fontName; + this.fontStyle = fontStyle; + } + + public static SText create(int x, int y, String text) { + var shape = new SText(x, y, text, DEFAULT_FONT_SIZE, DEFAULT_FONT_NAME, DEFAULT_FONT_STYLE); + shape.addAttributes(new SelectionAttributes()); + shape.addAttributes(new ColorAttributes(true, false, Color.BLACK, Color.BLACK)); + return shape; + } + + private static String normalizeText(String input) { + if (input == null || input.isBlank()) { + return PLACEHOLDER_TEXT; + } + return input; + } + + public String getText() { + return text; + } + + public int getFontSize() { + return fontSize; + } + + public String getFontName() { + return fontName; + } + + public int getFontStyle() { + return fontStyle; + } + + public void updateMeasuredBounds(int width, int height) { + getBounds().setSize(Math.max(width, 0), Math.max(height, 0)); + } + + @Override + public void accept(ShapeVisitor visitor) { + visitor.visitText(this); + } + + @Override + public Shape clone() { + var copy = new SText(getBounds().x, getBounds().y, text, fontSize, fontName, fontStyle); + copy.updateMeasuredBounds(getBounds().width, getBounds().height); + copy.addAttributes(new SelectionAttributes()); + + var attrs = (ColorAttributes) getAttributes(ColorAttributes.ID); + if (attrs != null) { + copy.addAttributes(new ColorAttributes(attrs.filled, attrs.stroked, attrs.filledColor, attrs.strokedColor)); + } + return copy; + } +} diff --git a/src/main/java/ovh/gasser/newshapes/ui/Controller.java b/src/main/java/ovh/gasser/newshapes/ui/Controller.java index e4b7c36..9dc2f24 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/Controller.java +++ b/src/main/java/ovh/gasser/newshapes/ui/Controller.java @@ -6,9 +6,11 @@ import ovh.gasser.newshapes.HTMLExporter; import ovh.gasser.newshapes.Selection; import ovh.gasser.newshapes.attributes.ColorAttributes; import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.SText; import ovh.gasser.newshapes.shapes.Shape; import ovh.gasser.newshapes.ui.listeners.SelectionListener; +import javax.swing.*; import java.awt.*; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; @@ -24,6 +26,7 @@ public class Controller { private final Selection selection; private Point lastMousePos; + private boolean addingText; Controller(ShapesView view, SCollection model) { this.view = view; @@ -62,6 +65,11 @@ public class Controller { } private void handleMousePressed(MouseEvent evt) { + if (addingText) { + placeTextAt(evt.getPoint()); + return; + } + getTarget(evt, this.model) .ifPresentOrElse( s -> { @@ -77,6 +85,22 @@ public class Controller { view.repaint(); } + public void enterTextMode() { + addingText = true; + } + + 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(); + view.repaint(); + } + private void handleKeyPressed(KeyEvent evt) { switch (evt.getKeyCode()) { case KeyEvent.VK_DELETE -> deleteSelected(); diff --git a/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java b/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java index bd439b5..a98c657 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java +++ b/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java @@ -88,6 +88,42 @@ public class ShapeDraftman implements ShapeVisitor { drawHandlerIfSelected(tri); } + @Override + public void visitText(SText text) { + ColorAttributes colAttrs = (ColorAttributes) text.getAttributes(ColorAttributes.ID); + + Font previousFont = g2d.getFont(); + Color previousColor = g2d.getColor(); + Font textFont = new Font(text.getFontName(), Font.PLAIN, text.getFontSize()); + g2d.setFont(textFont); + g2d.setColor(resolveTextColor(colAttrs)); + + FontMetrics metrics = g2d.getFontMetrics(textFont); + int width = metrics.stringWidth(text.getText()); + int height = metrics.getHeight(); + text.updateMeasuredBounds(width, height); + + Rectangle bounds = text.getBounds(); + g2d.drawString(text.getText(), bounds.x, bounds.y + metrics.getAscent()); + + g2d.setFont(previousFont); + g2d.setColor(previousColor); + drawHandlerIfSelected(text); + } + + private Color resolveTextColor(ColorAttributes attrs) { + if (attrs == null) { + return Color.BLACK; + } + if (attrs.filledColor != null) { + return attrs.filledColor; + } + if (attrs.strokedColor != null) { + return attrs.strokedColor; + } + return Color.BLACK; + } + private void drawHandlerIfSelected(Shape s) { SelectionAttributes selAttrs = (SelectionAttributes) s.getAttributes(SelectionAttributes.ID); if ((selAttrs != null) && (selAttrs.selected)){ diff --git a/src/main/java/ovh/gasser/newshapes/ui/listeners/MenuEditListener.java b/src/main/java/ovh/gasser/newshapes/ui/listeners/MenuEditListener.java index f3a26ec..dfe5295 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/listeners/MenuEditListener.java +++ b/src/main/java/ovh/gasser/newshapes/ui/listeners/MenuEditListener.java @@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory; import ovh.gasser.newshapes.attributes.ColorAttributes; import ovh.gasser.newshapes.attributes.SelectionAttributes; import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.SText; import ovh.gasser.newshapes.shapes.Shape; import ovh.gasser.newshapes.ui.Controller; import ovh.gasser.newshapes.ui.ShapesView; @@ -61,6 +62,10 @@ public class MenuEditListener implements ActionListener { logger.warn("No color attributes: {}", s); continue; } + if (s instanceof SText) { + s.addAttributes(new ColorAttributes(currentColAttrs.filled, currentColAttrs.stroked, filledColor, filledColor)); + continue; + } s.addAttributes(new ColorAttributes(true, currentColAttrs.stroked, filledColor, currentColAttrs.strokedColor)); } } @@ -74,6 +79,10 @@ public class MenuEditListener implements ActionListener { logger.warn("No color attributes: {}", s); continue; } + if (s instanceof SText) { + s.addAttributes(new ColorAttributes(currentColAttrs.filled, currentColAttrs.stroked, strockedColor, strockedColor)); + continue; + } s.addAttributes(new ColorAttributes(currentColAttrs.filled, true, currentColAttrs.filledColor, strockedColor)); } } @@ -85,6 +94,7 @@ public class MenuEditListener implements ActionListener { for (Shape s : model) { SelectionAttributes selAttrs = (SelectionAttributes) s.getAttributes(SelectionAttributes.ID); if ((selAttrs == null) || (!selAttrs.selected)) continue; + if (s instanceof SText) continue; ColorAttributes colAttrs = (ColorAttributes) s.getAttributes(ColorAttributes.ID); if (colAttrs == null) { logger.warn("No color attributes: {}", s); @@ -102,6 +112,7 @@ public class MenuEditListener implements ActionListener { for (Shape s : model) { SelectionAttributes selAttrs = (SelectionAttributes) s.getAttributes(SelectionAttributes.ID); if ((selAttrs == null) || (!selAttrs.selected)) continue; + if (s instanceof SText) continue; ColorAttributes colAttrs = (ColorAttributes) s.getAttributes(ColorAttributes.ID); if (colAttrs == null) { logger.warn("No color attributes: {}", s); diff --git a/src/main/java/ovh/gasser/newshapes/ui/visitors/HTMLDraftman.java b/src/main/java/ovh/gasser/newshapes/ui/visitors/HTMLDraftman.java index 0f12728..0a1419d 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/visitors/HTMLDraftman.java +++ b/src/main/java/ovh/gasser/newshapes/ui/visitors/HTMLDraftman.java @@ -7,10 +7,7 @@ import ovh.gasser.newshapes.shapes.Shape; import java.awt.*; import java.io.PrintWriter; -import java.util.Arrays; import java.util.StringJoiner; -import java.util.stream.Collectors; -import java.util.stream.Stream; public class HTMLDraftman implements ShapeVisitor { @@ -87,6 +84,27 @@ public class HTMLDraftman implements ShapeVisitor { cssOutput.write(strBuilder); } + @Override + public void visitText(SText text) { + int id = text.hashCode(); + htmlOutput.printf("
%s
\n", id, text.getText()); + + ColorAttributes attrs = (ColorAttributes) text.getAttributes(ColorAttributes.ID); + String color = formatCSSColor(resolveTextColor(attrs)); + + cssOutput.printf("#txt%d{\n", id); + cssOutput.println(" position: absolute;"); + cssOutput.printf(" top:%dpx;%n", text.getBounds().y); + cssOutput.printf(" left:%dpx;%n", text.getBounds().x); + cssOutput.printf(" font-family:%s;%n", quoteCssString(text.getFontName())); + cssOutput.printf(" font-size:%dpx;%n", text.getFontSize()); + cssOutput.printf(" font-style:%s;%n", (text.getFontStyle() & Font.ITALIC) != 0 ? "italic" : "normal"); + cssOutput.printf(" font-weight:%s;%n", (text.getFontStyle() & Font.BOLD) != 0 ? "bold" : "normal"); + cssOutput.printf(" color:%s;%n", color); + cssOutput.println(" white-space: nowrap;"); + cssOutput.println("}"); + } + private String attributesToCss(Shape shape) { ColorAttributes attrs = (ColorAttributes) shape.getAttributes(ColorAttributes.ID); String strokedColor = "#ffffff"; @@ -123,4 +141,21 @@ public class HTMLDraftman implements ShapeVisitor { visitCollection(model); htmlOutput.println(FOOTER_TEMPLATE); } + + private String quoteCssString(String raw) { + return "\"" + raw.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private Color resolveTextColor(ColorAttributes attrs) { + if (attrs == null) { + return Color.BLACK; + } + if (attrs.filledColor != null) { + return attrs.filledColor; + } + if (attrs.strokedColor != null) { + return attrs.strokedColor; + } + return Color.BLACK; + } } diff --git a/src/main/java/ovh/gasser/newshapes/ui/visitors/SVGDraftman.java b/src/main/java/ovh/gasser/newshapes/ui/visitors/SVGDraftman.java index c1e1f7c..3ffc0e4 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/visitors/SVGDraftman.java +++ b/src/main/java/ovh/gasser/newshapes/ui/visitors/SVGDraftman.java @@ -6,6 +6,7 @@ import ovh.gasser.newshapes.attributes.ColorAttributes; import ovh.gasser.newshapes.shapes.SCircle; import ovh.gasser.newshapes.shapes.SCollection; import ovh.gasser.newshapes.shapes.SRectangle; +import ovh.gasser.newshapes.shapes.SText; import ovh.gasser.newshapes.shapes.STriangle; import java.awt.*; @@ -71,6 +72,26 @@ public class SVGDraftman implements ShapeVisitor { this.output.printf("\n", points, style); } + @Override + public void visitText(SText sText) { + Rectangle bounds = sText.getBounds(); + ColorAttributes attrs = (ColorAttributes) sText.getAttributes(ColorAttributes.ID); + String color = colorToHex(resolveTextColor(attrs)); + String fontStyle = (sText.getFontStyle() & Font.ITALIC) != 0 ? "italic" : "normal"; + String fontWeight = (sText.getFontStyle() & Font.BOLD) != 0 ? "bold" : "normal"; + this.output.printf( + "%s\n", + bounds.x, + bounds.y + sText.getFontSize(), + sText.getFontName(), + sText.getFontSize(), + fontStyle, + fontWeight, + color, + sText.getText() + ); + } + public void generateSVG(SCollection model) { output.println(String.format(SVG_PRELUDE, App.WIN_SIZE.width, App.WIN_SIZE.height)); visitCollection(model); @@ -99,4 +120,17 @@ public class SVGDraftman implements ShapeVisitor { } return params; } + + private Color resolveTextColor(ColorAttributes attrs) { + if (attrs == null) { + return Color.BLACK; + } + if (attrs.filledColor != null) { + return attrs.filledColor; + } + if (attrs.strokedColor != null) { + return attrs.strokedColor; + } + return Color.BLACK; + } } diff --git a/style.css b/style.css index f4fd3e3..4156b42 100644 --- a/style.css +++ b/style.css @@ -1,7 +1,7 @@ -.triangle110717522{ +.triangle204514374{ position: absolute; - top: 162px; - left: 367px; + top: 169px; + left: 372px; width: 0px; height: 0px; border: 0 solid transparent; @@ -9,35 +9,35 @@ border-right-width: 30.0px; border-bottom: 50px solid #ffff00; } -#rec209793789{ +#rec664997016{ position:absolute; top:10px; left:10px; width:40px; height:60px; background:#ffffff;border:1px solid #ff0000; } -#rec365617083{ +#rec2139424642{ position:absolute; top:10px; left:70px; width:40px; height:60px; background:#ffffff;border:1px solid #000000; } -#rec1121327988{ +#rec609258999{ position:absolute; top:200px; left:100px; width:40px; height:60px; background:#ffffff;border:1px solid #ff00ff; } -#rec256914054{ +#rec759898031{ position:absolute; top:200px; left:150px; width:40px; height:60px; background:#ffffff;border:1px solid #ff00ff; } - .circle172224331{ + .circle305332562{ position: absolute; top:250px; @@ -52,3 +52,14 @@ background:#ffffff;border:1px solid #ff00ff; } background:#ffffff;border:1px solid #000000; } +#txt1172155777{ + position: absolute; + top:125px; + left:216px; + font-family:"SansSerif"; + font-size:16px; + font-style:normal; + font-weight:normal; + color:#9999ff; + white-space: nowrap; +}