Last active
May 25, 2016 23:41
-
-
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/)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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