feat: implement SText shape

This commit is contained in:
2026-03-19 21:45:05 +01:00
parent bd9c6c4b7d
commit f8a6d786ee
10 changed files with 256 additions and 26 deletions

View File

@@ -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

View File

@@ -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<Shape> 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<Shape> 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) {

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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)){

View File

@@ -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);

View File

@@ -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("<div id=\"txt%d\">%s</div>\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;
}
}

View File

@@ -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("<polygon points=\"%s\" style=\"%s\" />\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(
"<text x=\"%d\" y=\"%d\" font-family=\"%s\" font-size=\"%d\" font-style=\"%s\" font-weight=\"%s\" fill=\"%s\">%s</text>\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;
}
}

View File

@@ -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;
}