Skip to content

Instantly share code, notes, and snippets.

@james-d
Last active May 25, 2016 23:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save james-d/458f8af01da13fad0a29c8f9fbf622df to your computer and use it in GitHub Desktop.
Save james-d/458f8af01da13fad0a29c8f9fbf622df to your computer and use it in GitHub Desktop.
Experiment with UndoFX and dragging to move/resize a rectangle. Requires [UndoFX](https://github.com/TomasMikula/UndoFX/)
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>undofx-test</groupId>
<artifactId>undofx-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<build>
<sourceDirectory>src</sourceDirectory>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.3</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.fxmisc.undo</groupId>
<artifactId>undofx</artifactId>
<version>1.2</version>
</dependency>
</dependencies>
</project>
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import org.fxmisc.undo.UndoManager;
import org.fxmisc.undo.UndoManagerFactory;
import org.reactfx.EventStream;
import org.reactfx.EventStreams;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.value.ObservableValue;
import javafx.geometry.BoundingBox;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.util.Duration;
public class UndoRectangle extends Application {
private static final int RESIZE_BORDER = 10;
private static final Duration REDO_ANIMATION_TIME = Duration.seconds(0.2);
@Override
public void start(Stage primaryStage) {
// our rectangle, for dragging around and resizing
Rectangle rect = new Rectangle(50, 50, 150, 100);
rect.setFill(Color.CORNFLOWERBLUE);
// an animation for performing undo and redo:
Timeline redoAnimation = new Timeline();
// need to keep track of when the animation is running:
BooleanBinding animationRunning = redoAnimation.statusProperty().isEqualTo(Animation.Status.RUNNING);
// individual event streams for the properties we want to undo.
// Created by a utility method below: we specify the property whose
// changes comprise the event stream, and how we compute a new bounding box
// when the event stream emits a change:
EventStream<UndoChange<BoundingBox>> xChanges = makeEventStream(
rect.xProperty(),
x -> new BoundingBox(x.doubleValue(), rect.getY(), rect.getWidth(), rect.getHeight()));
EventStream<UndoChange<BoundingBox>> yChanges = makeEventStream(
rect.yProperty(),
y -> new BoundingBox(rect.getX(), y.doubleValue(), rect.getWidth(), rect.getHeight()));
EventStream<UndoChange<BoundingBox>> widthChanges = makeEventStream(
rect.widthProperty(),
w -> new BoundingBox(rect.getX(), rect.getY(), w.doubleValue(), rect.getHeight()));
EventStream<UndoChange<BoundingBox>> heightChanges = makeEventStream(
rect.heightProperty(),
h -> new BoundingBox(rect.getX(), rect.getY(), rect.getWidth(), h.doubleValue()));
// Merge the individual property event streams into a single stream:
EventStream<UndoChange<BoundingBox>> boundsChanges = EventStreams
.merge(xChanges, yChanges, widthChanges, heightChanges)
// we want to be able to merge changes into a single change;
// the UndoChange class already defines how to merge them:
.reducible(UndoChange::merge)
// and we don't want to emit changes while the undo/redo
// animation is running. We want the animation to be represented
// by a single change, which will be the change expected by the
// undo manager. Hence we suspect the event stream when the animation
// is running:
.suspendWhen(animationRunning);
// Now create the undo manager, specifying:
UndoManager undoManager = UndoManagerFactory.unlimitedHistoryUndoManager(
// the changes that can be undone:
boundsChanges,
// how to invert a change:
UndoChange::invert,
// how to perform a redo. Here we set the animation properties based
// on the change and play the animation:
c -> {
redoAnimation.getKeyFrames()
.setAll(new KeyFrame(REDO_ANIMATION_TIME,
new KeyValue(rect.xProperty(), c.getNewValue().getMinX()),
new KeyValue(rect.yProperty(), c.getNewValue().getMinY()),
new KeyValue(rect.widthProperty(), c.getNewValue().getWidth()),
new KeyValue(rect.heightProperty(), c.getNewValue().getHeight())));
redoAnimation.play();
} ,
// how to merge two changes together:
(c1, c2) -> Optional.of(c1.merge(c2))
);
// make each complete drag a separate "undo" event:
rect.setOnMouseReleased(e -> undoManager.preventMerge());
// now the controls:
Button undo = new Button("Undo");
// disable the button if no undo is available:
undo.disableProperty().bind(Bindings.not(undoManager.undoAvailableProperty()));
// execute the undo when the button is pressed:
undo.setOnAction(e -> undoManager.undo());
// Redo button is similar:
Button redo = new Button("Redo");
redo.disableProperty().bind(Bindings.not(undoManager.redoAvailableProperty()));
redo.setOnAction(e -> undoManager.redo());
// Container for buttons:
HBox buttons = new HBox(5, undo, redo);
// Disable both buttons during undo/redo animation:
buttons.disableProperty().bind(animationRunning);
// Layout (in real life do this in FXML or CSS...)
buttons.setAlignment(Pos.CENTER);
buttons.setPadding(new Insets(5));
// Set up the dragging functionality:
setUpDragging(rect);
// Change the cursor to indicate what happens during dragging:
setUpCursor(rect);
// Container for rectangle to drag around:
Pane pane = new Pane(rect);
// Root container:
BorderPane root = new BorderPane(pane, null, null, buttons, null);
Scene scene = new Scene(root, 600, 600);
primaryStage.setScene(scene);
primaryStage.show();
}
// Utility method for generating event stream of UndoChange from property:
private <S, T> EventStream<UndoChange<T>> makeEventStream(
// the property whose changes comprise the stream:
ObservableValue<S> property,
// a function to map property values to the objects held in the UndoChange:
Function<S, T> objectSupplier) {
// create event stream of property changes, and...
return EventStreams.changesOf(property).map(
// ... when the property changes generate an object from the old value and one
// from the new value, and use those to create the UndoChange:
c -> new UndoChange<>(objectSupplier.apply(c.getOldValue()), objectSupplier.apply(c.getNewValue())));
}
private void setUpDragging(Rectangle rect) {
// struct-like class to represent dragging state:
// last location of mouse, and function to map drag delta (change in x and y)
// to action to perform (i.e. move and/or resize rectangle):
class Dragger {
double x, y;
Consumer<Point2D> adjust ;
}
Dragger dragger = new Dragger();
rect.addEventHandler(MouseEvent.MOUSE_PRESSED, e -> {
// record the mouse location and figure out the dragging
// behavior based on the mouse coordinates relative to
// the rectangle:
dragger.x = e.getSceneX();
dragger.y = e.getSceneY();
dragger.adjust = computeAdjust(rect, e.getX(), e.getY());
});
rect.addEventHandler(MouseEvent.MOUSE_DRAGGED, e -> {
// compute how much the mouse moved:
double deltaX = e.getSceneX() - dragger.x;
double deltaY = e.getSceneY() - dragger.y;
// apply the dragging behavior:
dragger.adjust.accept(new Point2D(deltaX, deltaY));
// update the mouse location:
dragger.x = e.getSceneX();
dragger.y = e.getSceneY();
});
}
private Consumer<Point2D> computeAdjust(Rectangle rect, double x, double y) {
boolean resizeLeft = x < rect.getX() + RESIZE_BORDER;
boolean resizeRight = x > rect.getX() + rect.getWidth() - RESIZE_BORDER;
boolean resizeUp = y < rect.getY() + RESIZE_BORDER;
boolean resizeDown = y > rect.getY() + rect.getHeight() - RESIZE_BORDER;
// to resize to the left or up, we move in the direction of the drag and
// reduce the size. E.g. for resizing on the left edge, with a mouse drag
// in the positive direction (right), we want to move the rectangle right
// and decrease its size.
// to resize to the right or down, we increase the size (without moving).
// in the center, we just move (no resize)
// in the center-top and center-bottom, we do nothing in the horizontal direction,
// in the left-center, and right-center, we do nothing in the vertical direction.
// We represent these rules with a series of matrices indicating the
// horizontal and vertical move and resize behavior for each region:
int[][] horizMoveFactors = {
{ 1, 0, 0 },
{ 1, 1, 0 },
{ 1, 0, 0 }
};
int[][] horizResizeFactors = {
{ -1, 0, 1 },
{ -1, 0, 1 },
{ -1, 0, 1 }
};
int[][] vertMoveFactors = {
{ 1, 1, 1 },
{ 0, 1, 0 },
{ 0, 0, 0 }
};
int[][] vertResizeFactors = {
{ -1, -1, -1 },
{ 0, 0, 0 },
{ 1, 1, 1 }
};
// convert the booleans defined above to column/row in the matrices:
int horizRegion = resizeRight ? 2 : resizeLeft ? 0 : 1 ;
int vertRegion = resizeDown ? 2 : resizeUp ? 0 : 1 ;
// select values from matrices:
int horizMove = horizMoveFactors[vertRegion][horizRegion] ;
int horizResize = horizResizeFactors[vertRegion][horizRegion];
int vertMove = vertMoveFactors[vertRegion][horizRegion];
int vertResize = vertResizeFactors[vertRegion][horizRegion];
// and now define the adjust behavior for a given change in location,
// represented as the Point2D delta below:
return delta -> {
rect.setX(rect.getX() + horizMove * delta.getX());
rect.setWidth(rect.getWidth() + horizResize * delta.getX());
rect.setY(rect.getY() + vertMove * delta.getY());
rect.setHeight(rect.getHeight() + vertResize * delta.getY());
};
}
private void setUpCursor(Rectangle rect) {
rect.addEventHandler(MouseEvent.MOUSE_MOVED,
e -> rect.setCursor(selectCursor(rect, e.getX(), e.getY())));
rect.addEventHandler(MouseEvent.MOUSE_RELEASED,
e -> rect.setCursor(Cursor.DEFAULT));
}
private Cursor selectCursor(Rectangle rect, double x, double y) {
boolean resizeWest = x < rect.getX() + RESIZE_BORDER;
boolean resizeEast = x > rect.getX() + rect.getWidth() - RESIZE_BORDER;
boolean resizeNorth = y < rect.getY() + RESIZE_BORDER;
boolean resizeSouth = y > rect.getY() + rect.getHeight() - RESIZE_BORDER;
// convert booleans to column and row in matrix:
int horizResize = resizeWest ? 0 : resizeEast ? 2 : 1;
int vertResize = resizeNorth ? 0 : resizeSouth ? 2 : 1;
// matrix of cursors:
Cursor[][] cursors = {
{ Cursor.NW_RESIZE, Cursor.N_RESIZE, Cursor.NE_RESIZE },
{ Cursor.W_RESIZE, Cursor.MOVE, Cursor.E_RESIZE },
{ Cursor.SW_RESIZE, Cursor.S_RESIZE, Cursor.SE_RESIZE } };
return cursors[vertResize][horizResize];
}
// class to represent a change we can undo:
public static class UndoChange<T> {
private final T oldValue;
private final T newValue;
public UndoChange(T oldValue, T newValue) {
super();
this.oldValue = oldValue;
this.newValue = newValue;
}
public T getOldValue() {
return oldValue;
}
public T getNewValue() {
return newValue;
}
// merge with another change:
public UndoChange<T> merge(UndoChange<T> other) {
return new UndoChange<>(oldValue, other.newValue);
}
// flip undo to redo and v.v.:
public UndoChange<T> invert() {
return new UndoChange<>(newValue, oldValue);
}
@Override
public int hashCode() {
return Objects.hash(oldValue, newValue);
}
@Override
public boolean equals(Object obj) {
if (obj instanceof UndoChange) {
UndoChange<?> other = (UndoChange<?>) obj;
return Objects.equals(oldValue, other.oldValue)
&& Objects.equals(newValue, other.newValue);
} else
return false;
}
}
public static void main(String[] args) {
launch(args);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment