Added better user interface controls and panning.

This commit is contained in:
Andrew Lalis 2021-02-16 13:00:31 +01:00
parent 471db2f60a
commit d5546a5320
18 changed files with 272 additions and 55 deletions

View File

@ -6,7 +6,7 @@
<groupId>nl.andrewlalis</groupId>
<artifactId>EntityRelationMappingEditor</artifactId>
<version>1.1.0</version>
<version>1.2.0</version>
<build>
<plugins>
<plugin>

View File

@ -4,7 +4,7 @@ import com.formdev.flatlaf.FlatLightLaf;
import nl.andrewlalis.erme.view.EditorFrame;
public class EntityRelationMappingEditor {
public static final String VERSION = "1.1.0";
public static final String VERSION = "1.2.0";
public static void main(String[] args) {
if (!FlatLightLaf.install()) {

View File

@ -84,23 +84,13 @@ public class ExportToImageAction extends AbstractAction {
private BufferedImage renderModel() {
BufferedImage bufferedImage = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = bufferedImage.createGraphics();
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
for (Relation r : model.getRelations()) {
Rectangle bounds = r.getViewModel().getBounds(g2d);
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + bounds.width);
maxY = Math.max(maxY, bounds.y + bounds.height);
}
BufferedImage outputImage = new BufferedImage((maxX - minX), (maxY - minY) + 20, BufferedImage.TYPE_INT_RGB);
final Rectangle bounds = this.model.getViewModel().getBounds(g2d);
BufferedImage outputImage = new BufferedImage(bounds.width, bounds.height + 20, BufferedImage.TYPE_INT_RGB);
g2d = outputImage.createGraphics();
g2d.setColor(Color.WHITE);
g2d.fillRect(outputImage.getMinX(), outputImage.getMinY(), outputImage.getWidth(), outputImage.getHeight());
AffineTransform originalTransform = g2d.getTransform();
g2d.setTransform(AffineTransform.getTranslateInstance(-minX, -minY));
g2d.setTransform(AffineTransform.getTranslateInstance(-bounds.x, -bounds.y));
List<Relation> selectedRelations = this.model.getSelectedRelations();
this.model.getSelectedRelations().forEach(r -> r.setSelected(false));

View File

@ -0,0 +1,43 @@
package nl.andrewlalis.erme.control.actions;
import lombok.Setter;
import nl.andrewlalis.erme.model.*;
import nl.andrewlalis.erme.view.DiagramPanel;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
public class LoadSampleModelAction extends AbstractAction {
private static LoadSampleModelAction instance;
public static LoadSampleModelAction getInstance() {
if (instance == null) {
instance = new LoadSampleModelAction();
}
return instance;
}
@Setter
private DiagramPanel diagramPanel;
public LoadSampleModelAction() {
super("Load Sample Model");
this.putValue(SHORT_DESCRIPTION, "Loads a sample ER-mapping model.");
}
@Override
public void actionPerformed(ActionEvent e) {
MappingModel model = new MappingModel();
Relation r0 = new Relation(model, new Point(50, 20), "AirplaneType");
r0.addAttribute(new Attribute(r0, AttributeType.ID_KEY, "name"));
r0.addAttribute(new Attribute(r0, AttributeType.PLAIN, "manufacturer"));
model.addRelation(r0);
Relation r1 = new Relation(model, new Point(50, 100), "Airplane");
r1.addAttribute(new Attribute(r1, AttributeType.ID_KEY, "id"));
r1.addAttribute(new Attribute(r1, AttributeType.PLAIN, "purchasedAt"));
r1.addAttribute(new ForeignKeyAttribute(r1, AttributeType.PLAIN, "typeName", "AirplaneType", "name"));
model.addRelation(r1);
this.diagramPanel.setModel(model);
}
}

View File

@ -7,7 +7,6 @@ import nl.andrewlalis.erme.view.DiagramPopupMenu;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.InputEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
@ -31,22 +30,29 @@ public class DiagramMouseListener extends MouseAdapter {
*/
@Override
public void mousePressed(MouseEvent e) {
DiagramPanel panel = (DiagramPanel) e.getSource();
final Graphics2D g2d = panel.getGraphics2D();
final DiagramPanel panel = (DiagramPanel) e.getSource();
final Graphics2D g = panel.getGraphics2D();
this.mouseDragStart = e.getPoint();
final int modelX = e.getX() - panel.getPanningTranslation().x;
final int modelY = e.getY() - panel.getPanningTranslation().y;
boolean isCtrlDown = (e.getModifiers() & ActionEvent.CTRL_MASK) == ActionEvent.CTRL_MASK;
final boolean isCtrlDown = (e.getModifiers() & ActionEvent.CTRL_MASK) == ActionEvent.CTRL_MASK;
final boolean isShiftDown = (e.getModifiers() & ActionEvent.SHIFT_MASK) == ActionEvent.SHIFT_MASK;
if (!isCtrlDown) {
if (!isShiftDown && !isCtrlDown) {// A simple click anywhere should reset selection.
this.model.getRelations().forEach(r -> r.setSelected(false));
}
if (!isShiftDown) {// If the user clicked or CTRL+clicked, try and select the relation they clicked on.
for (Relation r : this.model.getRelations()) {
if (r.getViewModel().getBounds(g2d).contains(e.getX(), e.getY())) {
if (r.getViewModel().getBounds(g).contains(modelX, modelY)) {
r.setSelected(!r.isSelected());
break;
}
}
}
// If the user right-clicked, show a popup menu.
if (e.getButton() == MouseEvent.BUTTON3) {
DiagramPopupMenu popupMenu = new DiagramPopupMenu(this.model);
popupMenu.show(panel, e.getX(), e.getY());
@ -62,15 +68,24 @@ public class DiagramMouseListener extends MouseAdapter {
@Override
public void mouseDragged(MouseEvent e) {
int dx = this.mouseDragStart.x - e.getX();
int dy = this.mouseDragStart.y - e.getY();
final int dx = this.mouseDragStart.x - e.getX();
final int dy = this.mouseDragStart.y - e.getY();
final boolean isShiftDown = (e.getModifiers() & ActionEvent.SHIFT_MASK) == ActionEvent.SHIFT_MASK;
boolean changed = false;
if (isShiftDown) {
final DiagramPanel panel = (DiagramPanel) e.getSource();
panel.translate(-dx, -dy);
panel.repaint();
} else {
for (Relation r : this.model.getRelations()) {
if (r.isSelected()) {
r.setPosition(new Point(r.getPosition().x - dx, r.getPosition().y - dy));
changed = true;
}
}
}
if (changed) {
this.model.fireChangedEvent();
}

View File

@ -59,4 +59,8 @@ public class Attribute implements Serializable {
public String toString() {
return this.getName();
}
public Attribute copy(Relation newRelation) {
return new Attribute(newRelation, this.getType(), this.getName());
}
}

View File

@ -31,4 +31,9 @@ public class ForeignKeyAttribute extends Attribute {
public String toString() {
return super.toString() + "->" + this.getFullReferenceName();
}
@Override
public ForeignKeyAttribute copy(Relation newRelation) {
return new ForeignKeyAttribute(newRelation, this.getType(), this.getName(), this.getReference());
}
}

View File

@ -1,7 +1,10 @@
package nl.andrewlalis.erme.model;
import lombok.Getter;
import nl.andrewlalis.erme.view.view_models.MappingModelViewModel;
import nl.andrewlalis.erme.view.view_models.ViewModel;
import java.awt.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.List;
@ -13,7 +16,7 @@ import java.util.stream.Collectors;
* This model contains all the information about a single mapping diagram,
* including each mapped table and the links between them.
*/
public class MappingModel implements Serializable {
public class MappingModel implements Serializable, Viewable {
@Getter
private final Set<Relation> relations;
@ -79,6 +82,23 @@ public class MappingModel implements Serializable {
this.changeListeners.forEach(ModelChangeListener::onModelChanged);
}
/**
* Updates the positions of all relations so that the bounding box for this
* model starts at 0, 0.
*/
public final void normalizeRelationPositions() {
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
for (Relation r : this.getRelations()) {
minX = Math.min(minX, r.getPosition().x);
minY = Math.min(minY, r.getPosition().y);
}
for (Relation r : this.getRelations()) {
final Point current = r.getPosition();
r.setPosition(new Point(current.x - minX, current.y - minY));
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -91,4 +111,15 @@ public class MappingModel implements Serializable {
public int hashCode() {
return Objects.hash(this.getRelations());
}
@Override
public ViewModel getViewModel() {
return new MappingModelViewModel(this);
}
public MappingModel copy() {
MappingModel c = new MappingModel();
this.getRelations().forEach(r -> c.addRelation(r.copy(c)));
return c;
}
}

View File

@ -2,6 +2,7 @@ package nl.andrewlalis.erme.model;
import lombok.Getter;
import nl.andrewlalis.erme.view.view_models.RelationViewModel;
import nl.andrewlalis.erme.view.view_models.ViewModel;
import java.awt.*;
import java.io.Serializable;
@ -14,7 +15,7 @@ import java.util.stream.Collectors;
* Represents a single "relation" or table in the diagram.
*/
@Getter
public class Relation implements Serializable {
public class Relation implements Serializable, Viewable {
private final MappingModel model;
private Point position;
private String name;
@ -59,7 +60,8 @@ public class Relation implements Serializable {
}
}
public RelationViewModel getViewModel() {
@Override
public ViewModel getViewModel() {
if (this.viewModel == null) {
this.viewModel = new RelationViewModel(this);
}
@ -89,4 +91,10 @@ public class Relation implements Serializable {
public String toString() {
return this.getName();
}
public Relation copy(MappingModel newModel) {
Relation c = new Relation(newModel, new Point(this.getPosition()), this.getName());
this.getAttributes().forEach(a -> c.addAttribute(a.copy(c)));
return c;
}
}

View File

@ -0,0 +1,7 @@
package nl.andrewlalis.erme.model;
import nl.andrewlalis.erme.view.view_models.ViewModel;
public interface Viewable {
ViewModel getViewModel();
}

View File

@ -1,10 +1,7 @@
package nl.andrewlalis.erme.view;
import lombok.Getter;
import nl.andrewlalis.erme.control.actions.ExportToImageAction;
import nl.andrewlalis.erme.control.actions.LoadAction;
import nl.andrewlalis.erme.control.actions.NewModelAction;
import nl.andrewlalis.erme.control.actions.SaveAction;
import nl.andrewlalis.erme.control.actions.*;
import nl.andrewlalis.erme.control.actions.edits.AddAttributeAction;
import nl.andrewlalis.erme.control.actions.edits.AddRelationAction;
import nl.andrewlalis.erme.control.actions.edits.RemoveAttributeAction;
@ -12,10 +9,11 @@ import nl.andrewlalis.erme.control.actions.edits.RemoveRelationAction;
import nl.andrewlalis.erme.control.diagram.DiagramMouseListener;
import nl.andrewlalis.erme.model.MappingModel;
import nl.andrewlalis.erme.model.ModelChangeListener;
import nl.andrewlalis.erme.view.view_models.MappingModelViewModel;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
@ -26,8 +24,30 @@ public class DiagramPanel extends JPanel implements ModelChangeListener {
@Getter
private MappingModel model;
@Getter
private final Point panningTranslation;
public DiagramPanel(MappingModel model) {
super(true);
this.panningTranslation = new Point();
InputMap inputMap = this.getInputMap(WHEN_IN_FOCUSED_WINDOW);
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, KeyEvent.SHIFT_DOWN_MASK), "PAN_RESET");
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, KeyEvent.CTRL_DOWN_MASK), "CENTER_MODEL");
this.getActionMap().put("PAN_RESET", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
resetTranslation();
repaint();
}
});
this.getActionMap().put("CENTER_MODEL", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
model.normalizeRelationPositions();
centerModel();
repaint();
}
});
this.setModel(model);
}
@ -44,13 +64,38 @@ public class DiagramPanel extends JPanel implements ModelChangeListener {
this.addMouseListener(listener);
this.addMouseMotionListener(listener);
this.updateActionModels();
this.centerModel();
this.repaint();
}
public void translate(int dx, int dy) {
this.panningTranslation.x += dx;
this.panningTranslation.y += dy;
}
public void resetTranslation() {
this.panningTranslation.x = 0;
this.panningTranslation.y = 0;
}
public void centerModel() {
if (this.getGraphics() == null) {
return;
}
final Rectangle modelBounds = this.getModel().getViewModel().getBounds(this.getGraphics2D());
final int modelCenterX = modelBounds.x + modelBounds.width / 2;
final int modelCenterY = modelBounds.y + modelBounds.height / 2;
final int panelCenterX = this.getWidth() / 2;
final int panelCenterY = this.getHeight() / 2;
this.resetTranslation();
this.translate(panelCenterX - modelCenterX, panelCenterY - modelCenterY);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
new MappingModelViewModel(this.model).draw(this.getGraphics2D(g));
g.translate(this.panningTranslation.x, this.panningTranslation.y);
this.model.getViewModel().draw(this.getGraphics2D(g));
}
public Graphics2D getGraphics2D(Graphics g) {
@ -83,5 +128,6 @@ public class DiagramPanel extends JPanel implements ModelChangeListener {
RemoveRelationAction.getInstance().setModel(this.model);
AddAttributeAction.getInstance().setModel(this.model);
RemoveAttributeAction.getInstance().setModel(this.model);
LoadSampleModelAction.getInstance().setDiagramPanel(this);
}
}

View File

@ -16,7 +16,7 @@ public class EditorFrame extends JFrame {
this.setMinimumSize(new Dimension(400, 400));
this.setPreferredSize(new Dimension(800, 800));
this.pack();
this.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
this.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
this.setLocationRelativeTo(null);
}
}

View File

@ -45,6 +45,7 @@ public class EditorMenuBar extends JMenuBar {
private JMenu buildHelpMenu() {
JMenu menu = new JMenu("Help");
menu.add(InstructionsAction.getInstance());
menu.add(LoadSampleModelAction.getInstance());
menu.add(AboutAction.getInstance());
return menu;
}

View File

@ -9,6 +9,9 @@ import java.awt.font.TextAttribute;
import java.awt.geom.Rectangle2D;
import java.text.AttributedString;
/**
* View model for rendering a single attribute of a relation.
*/
public class AttributeViewModel implements ViewModel {
public static final int PADDING_X = 5;
public static final int PADDING_Y = 5;
@ -25,7 +28,7 @@ public class AttributeViewModel implements ViewModel {
@Override
public void draw(Graphics2D g) {
AttributedString as = this.getAttributedString(g);
Rectangle r = this.getBoxBounds(g, as);
Rectangle r = this.getBounds(g, as);
g.setColor(BACKGROUND_COLOR);
g.fillRect(r.x, r.y, r.width, r.height);
g.setColor(FONT_COLOR);
@ -40,12 +43,18 @@ public class AttributeViewModel implements ViewModel {
}
}
private Rectangle getBoxBounds(Graphics2D g, AttributedString as) {
@Override
public Rectangle getBounds(Graphics2D g) {
return this.getBounds(g, this.getAttributedString(g));
}
private Rectangle getBounds(Graphics2D g, AttributedString as) {
final RelationViewModel relationViewModel = (RelationViewModel) this.attribute.getRelation().getViewModel();
int x = this.attribute.getRelation().getPosition().x + RelationViewModel.PADDING_X;
int y = this.attribute.getRelation().getPosition().y + this.attribute.getRelation().getViewModel().getNameBounds(g).height + RelationViewModel.ATTRIBUTE_SEPARATION;
int y = this.attribute.getRelation().getPosition().y + relationViewModel.getNameBounds(g).height + RelationViewModel.ATTRIBUTE_SEPARATION;
int i = 0;
while (!this.attribute.getRelation().getAttributes().get(i).equals(this.attribute)) {
x += this.attribute.getRelation().getAttributes().get(i).getViewModel().getBoxBounds(g).width;
x += this.attribute.getRelation().getAttributes().get(i).getViewModel().getBounds(g).width;
i++;
}
Rectangle2D nameRect = g.getFontMetrics().getStringBounds(as.getIterator(), 0, this.attribute.getName().length(), g);
@ -62,10 +71,6 @@ public class AttributeViewModel implements ViewModel {
return new Rectangle(x, y, width, height);
}
public Rectangle getBoxBounds(Graphics2D g) {
return this.getBoxBounds(g, this.getAttributedString(g));
}
private AttributedString getAttributedString(Graphics2D g) {
AttributedString as = new AttributedString(this.attribute.getName());
as.addAttribute(TextAttribute.FONT, g.getFont());

View File

@ -5,6 +5,9 @@ import nl.andrewlalis.erme.model.Relation;
import java.awt.*;
/**
* View model for rendering an entire {@code MappingModel} object.
*/
public class MappingModelViewModel implements ViewModel {
private final MappingModel model;
@ -18,4 +21,20 @@ public class MappingModelViewModel implements ViewModel {
r.getViewModel().draw(g);
}
}
@Override
public Rectangle getBounds(Graphics2D g) {
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
for (Relation r : model.getRelations()) {
Rectangle bounds = r.getViewModel().getBounds(g);
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + bounds.width);
maxY = Math.max(maxY, bounds.y + bounds.height);
}
return new Rectangle(minX, minY, Math.abs(maxX - minX), Math.abs(maxY - minY));
}
}

View File

@ -8,11 +8,28 @@ import java.awt.font.TextAttribute;
import java.awt.geom.Rectangle2D;
import java.text.AttributedString;
/**
* View model which handles rendering a single relation.
*/
public class RelationViewModel implements ViewModel {
/**
* Padding that should be added to the left and right of the true bounds.
*/
public static final int PADDING_X = 5;
/**
* Padding that should be added to the top and bottom of the true bounds.
*/
public static final int PADDING_Y = 5;
/**
* The space between the relation's name and any attributes.
*/
public static final int ATTRIBUTE_SEPARATION = 10;
public static final Color SELECTED_COLOR = new Color(204, 224, 255);
public static final Color NAME_COLOR = Color.BLACK;
private final Relation relation;
public RelationViewModel(Relation relation) {
@ -23,17 +40,23 @@ public class RelationViewModel implements ViewModel {
public void draw(Graphics2D g) {
AttributedString as = this.getAttributedString(g);
Rectangle bounds = this.getBounds(g);
g.setColor(Color.BLACK);
if (this.relation.isSelected()) {
g.setColor(SELECTED_COLOR);
g.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
}
g.setColor(NAME_COLOR);
g.drawString(as.getIterator(), bounds.x + PADDING_X, bounds.y + this.getNameBounds(g).height - PADDING_Y);
for (Attribute a : this.relation.getAttributes()) {
a.getViewModel().draw(g);
}
if (this.relation.isSelected()) {
g.setColor(Color.BLUE);
g.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
}
}
/**
* Obtains the bounding box for the relation that'll be drawn. The bounding
* box contains the name of the relation and any attributes in it.
* @param g The graphics context.
* @return A rectangle describing the bounding box.
*/
public Rectangle getBounds(Graphics2D g) {
Rectangle rect = new Rectangle();
rect.x = this.relation.getPosition().x;
@ -41,7 +64,7 @@ public class RelationViewModel implements ViewModel {
int totalAttributeWidth = 0;
int maxAttributeHeight = 0;
for (Attribute a : this.relation.getAttributes()) {
Rectangle attributeBounds = a.getViewModel().getBoxBounds(g);
Rectangle attributeBounds = a.getViewModel().getBounds(g);
totalAttributeWidth += attributeBounds.width;
maxAttributeHeight = Math.max(maxAttributeHeight, attributeBounds.height);
}
@ -51,12 +74,24 @@ public class RelationViewModel implements ViewModel {
return rect;
}
/**
* Gets the bounding box around the relation's name, according to the font,
* font size, weight, etc.
* @param g The graphics context.
* @return A rectangle describing the name's bounding box.
*/
public Rectangle getNameBounds(Graphics2D g) {
AttributedString as = this.getAttributedString(g);
Rectangle2D nameBounds = g.getFontMetrics().getStringBounds(as.getIterator(), 0, this.relation.getName().length(), g);
return nameBounds.getBounds();
}
/**
* Gets an instance of AttributedString that can be used for display and
* reference purposes.
* @param g The graphics context.
* @return The attributed string, with all necessary attributes set.
*/
private AttributedString getAttributedString(Graphics2D g) {
AttributedString as = new AttributedString(this.relation.getName());
as.addAttribute(TextAttribute.FONT, g.getFont());

View File

@ -4,4 +4,6 @@ import java.awt.*;
public interface ViewModel {
void draw(Graphics2D g);
Rectangle getBounds(Graphics2D g);
}

View File

@ -28,7 +28,7 @@
The <em>Edit</em> menu contains options for making changes to the current model, such as adding or removing relations and attributes, or undoing/redoing actions.
</li>
<li>
The <em>Help</em> menu contains some items with additional information about the application, like this help page and a simple <em>About</em> popup with version information.
The <em>Help</em> menu contains some items with additional information about the application, like this help page and a simple <em>About</em> popup with version information. There's also a <em>Load Sample Model</em> option, which will load a very basic sample model into the application that you are free to mess around with.
</li>
</ul>
<p>
@ -47,6 +47,12 @@
<li>
Right-click on different areas to access a context menu with some helpful actions.
</li>
<li>
Hold <em>Shift</em> while dragging the mouse to pan across the model. To reset panning, press <em>Shift + Space</em>.
</li>
<li>
Press <em>Control + Space</em> to move the view so that the diagram is centered.
</li>
<li>
When exporting the model to an image, be sure to add the desired image file extension (.png, .jpg, .bmp, etc.), or the application will default to .png.
</li>