diff --git a/locales/en-US/server.ftl b/locales/en-US/server.ftl
index 9259b9803a..52795ef9c9 100644
--- a/locales/en-US/server.ftl
+++ b/locales/en-US/server.ftl
@@ -181,6 +181,10 @@ annotationPenButton =
.title = Pen
annotationHighlighterButton =
.title = Highlighter
+annotationUndoButton =
+ .title = Undo
+annotationRedoButton =
+ .title = Redo
# Note: This button reverts all the changes on the image since the start of the editing session.
annotationClearButton =
.title = Clear
diff --git a/server/src/pages/shot/drawing-tool.js b/server/src/pages/shot/drawing-tool.js
index d964b144a7..5a793a15b3 100644
--- a/server/src/pages/shot/drawing-tool.js
+++ b/server/src/pages/shot/drawing-tool.js
@@ -88,14 +88,14 @@ exports.DrawingTool = class DrawingTool extends React.Component {
return;
}
- this.drawnArea.left = Math.max(this.drawnArea.left - this.state.lineWidth, 0);
- this.drawnArea.top = Math.max(this.drawnArea.top - this.state.lineWidth, 0);
- this.drawnArea.right = Math.min(
+ this.drawnArea.left = Math.ceil(Math.max(this.drawnArea.left - this.state.lineWidth, 0));
+ this.drawnArea.top = Math.ceil(Math.max(this.drawnArea.top - this.state.lineWidth, 0));
+ this.drawnArea.right = Math.ceil(Math.min(
this.drawnArea.right + this.state.lineWidth,
- this.canvasWidth);
- this.drawnArea.bottom = Math.min(
+ this.canvasWidth));
+ this.drawnArea.bottom = Math.ceil(Math.min(
this.drawnArea.bottom + this.state.lineWidth,
- this.canvasHeight);
+ this.canvasHeight));
this.finalize();
diff --git a/server/src/pages/shot/editor-history.js b/server/src/pages/shot/editor-history.js
new file mode 100644
index 0000000000..00db0b91c1
--- /dev/null
+++ b/server/src/pages/shot/editor-history.js
@@ -0,0 +1,113 @@
+const { Selection } = require("../../../shared/selection");
+
+exports.EditorHistory = class {
+ constructor(devicePixelRatio) {
+ this.beforeEdits = [];
+ this.afterEdits = [];
+ this.devicePixelRatio = devicePixelRatio;
+ }
+
+ push(canvas, area, recordType) {
+ const record = new EditRecord(
+ canvas,
+ area,
+ this.devicePixelRatio,
+ recordType
+ );
+ this.beforeEdits.push(record);
+ this.afterEdits = [];
+ }
+
+ pushDiff(canvas, area) {
+ this.push(canvas, area, RecordType.DIFF);
+ }
+
+ pushFrame(canvas, area) {
+ this.push(canvas, area, RecordType.FRAME);
+ }
+
+ canUndo() {
+ return !!this.beforeEdits.length;
+ }
+
+ undo(canvasBeforeUndo) {
+ if (!this.canUndo()) {
+ return null;
+ }
+
+ return this._replay(canvasBeforeUndo, this.beforeEdits, this.afterEdits);
+ }
+
+ canRedo() {
+ return !!this.afterEdits.length;
+ }
+
+ redo(canvasBeforeRedo) {
+ if (!this.canRedo()) {
+ return null;
+ }
+
+ return this._replay(canvasBeforeRedo, this.afterEdits, this.beforeEdits);
+ }
+
+ _replay(canvasBeforeChange, from, to) {
+ const fromRecord = from.pop();
+
+ let area = fromRecord.area;
+ if (fromRecord.recordType === RecordType.FRAME) {
+ area = new Selection(
+ 0, 0,
+ parseInt(canvasBeforeChange.style.width, 10),
+ parseInt(canvasBeforeChange.style.height, 10)
+ );
+ }
+
+ const toRecord = new EditRecord(
+ canvasBeforeChange,
+ area,
+ this.devicePixelRatio,
+ fromRecord.recordType
+ );
+
+ to.push(toRecord);
+
+ return fromRecord;
+ }
+};
+
+class EditRecord {
+ constructor(canvas, area, devicePixelRatio, recordType) {
+ this.area = area;
+ this.recordType = recordType;
+ this.canvas = this.captureCanvas(canvas, area, devicePixelRatio, recordType);
+ }
+
+ captureCanvas(canvas, area, devicePixelRatio, recordType) {
+ const copy = document.createElement("canvas");
+
+ if (recordType === RecordType.FRAME) {
+ copy.width = canvas.width;
+ copy.height = canvas.height;
+ const copyContext = copy.getContext("2d");
+ copyContext.drawImage(canvas, 0, 0);
+ return copy;
+ }
+
+ copy.width = area.width;
+ copy.height = area.height;
+ const copyContext = copy.getContext("2d");
+ copyContext.drawImage(
+ canvas,
+ area.left * devicePixelRatio,
+ area.top * devicePixelRatio,
+ area.width * devicePixelRatio,
+ area.height * devicePixelRatio,
+ 0, 0, area.width, area.height
+ );
+
+ return copy;
+ }
+}
+
+const RecordType = { DIFF: 0, FRAME: 1 };
+exports.RecordType = RecordType;
diff --git a/server/src/pages/shot/editor.js b/server/src/pages/shot/editor.js
index cbe11ea7e8..7dba6ca725 100644
--- a/server/src/pages/shot/editor.js
+++ b/server/src/pages/shot/editor.js
@@ -6,6 +6,8 @@ const { PenTool } = require("./pen-tool");
const { HighlighterTool } = require("./highlighter-tool");
const { CropTool } = require("./crop-tool");
const { ColorPicker } = require("./color-picker");
+const { EditorHistory, RecordType } = require("./editor-history");
+const { Selection } = require("../../../shared/selection");
exports.Editor = class Editor extends React.Component {
constructor(props) {
@@ -15,17 +17,20 @@ exports.Editor = class Editor extends React.Component {
|| props.clip.image.captureType === "fullPageTruncated") {
this.devicePixelRatio = 1;
}
- this.canvasWidth = Math.floor(this.props.clip.image.dimensions.x);
- this.canvasHeight = Math.floor(this.props.clip.image.dimensions.y);
this.state = {
+ canvasWidth: Math.floor(this.props.clip.image.dimensions.x),
+ canvasHeight: Math.floor(this.props.clip.image.dimensions.y),
tool: "",
color: "",
lineWidth: "",
- actionsDisabled: true
+ actionsDisabled: true,
+ canUndo: false,
+ canRedo: false
};
this.onMouseUp = this.onMouseUp.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.selectedTool = React.createRef();
+ this.history = new EditorHistory(this.devicePixelRatio);
}
render() {
@@ -44,13 +49,13 @@ exports.Editor = class Editor extends React.Component {
+ style={{ height: this.state.canvasHeight, width: this.state.canvasWidth }}>
+ height={this.state.canvasHeight * this.devicePixelRatio} width={this.state.canvasWidth * this.devicePixelRatio}
+ style={{ height: this.state.canvasHeight, width: this.state.canvasWidth }}>
{toolContent}
;
@@ -91,6 +96,13 @@ exports.Editor = class Editor extends React.Component {
this.setState({overrideToolbar: this.state.tool});
}
+ deriveButtonStates() {
+ this.setState({
+ canUndo: this.history.canUndo(),
+ canRedo: this.history.canRedo()
+ });
+ }
+
isToolActive(tool) {
return this.state.tool === tool;
}
@@ -99,14 +111,19 @@ exports.Editor = class Editor extends React.Component {
if (this.selectedTool.current && this.selectedTool.current.renderToolbar) {
return this.selectedTool.current.renderToolbar();
}
+
const penState = this.isToolActive("pen") ? "active" : "inactive";
const highlighterState = this.isToolActive("highlighter") ? "active" : "inactive";
+ const undoButtonState = this.state.canUndo ? "active" : "inactive";
+ const redoButtonState = this.state.canRedo ? "active" : "inactive";
+
return
+
@@ -116,8 +133,17 @@ exports.Editor = class Editor extends React.Component {
+
+
+
+
+
+
+
-
+
@@ -155,6 +181,8 @@ exports.Editor = class Editor extends React.Component {
return;
}
+ this.history.pushDiff(this.imageCanvas, affectedArea);
+
this.imageContext.globalCompositeOperation = (compositeOp || "source-over");
this.imageContext.drawImage(incomingCanvas,
affectedArea.left * this.devicePixelRatio,
@@ -162,6 +190,15 @@ exports.Editor = class Editor extends React.Component {
affectedArea.width * this.devicePixelRatio,
affectedArea.height * this.devicePixelRatio,
affectedArea.left, affectedArea.top, affectedArea.width, affectedArea.height);
+
+ this.deriveButtonStates();
+ }
+
+ applyDiff(area, diffCanvas) {
+ this.imageContext.globalCompositeOperation = "source-over";
+ this.imageContext.drawImage(diffCanvas,
+ 0, 0, diffCanvas.width, diffCanvas.height,
+ area.left, area.top, area.width, area.height);
}
onCropUpdate(affectedArea, incomingCanvas) {
@@ -170,29 +207,73 @@ exports.Editor = class Editor extends React.Component {
return;
}
+ this.history.pushFrame(this.imageCanvas, new Selection(
+ 0, 0, this.state.canvasWidth, this.state.canvasHeight
+ ));
+ this.applyFrame(affectedArea, incomingCanvas);
+ this.setState({tool: this.previousTool});
+ this.deriveButtonStates();
+ }
+
+ applyFrame(area, frameCanvas) {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => {
imageContext.scale(this.devicePixelRatio, this.devicePixelRatio);
- imageContext.drawImage(img, 0, 0, affectedArea.width, affectedArea.height);
+ imageContext.drawImage(img, 0, 0, area.width, area.height);
};
- this.canvasWidth = affectedArea.width;
- this.canvasHeight = affectedArea.height;
const imageContext = this.imageCanvas.getContext("2d");
this.imageContext = imageContext;
- img.src = incomingCanvas.toDataURL("image/png");
- this.setState({tool: this.previousTool});
+ img.src = frameCanvas.toDataURL("image/png");
+ this.setState({canvasWidth: area.width, canvasHeight: area.height});
}
onClickCancelCrop() {
this.setState({tool: this.previousTool});
}
+ onUndo() {
+ if (!this.history.canUndo()) {
+ return;
+ }
+
+ this.applyHistory(this.history.undo(this.imageCanvas));
+ this.deriveButtonStates();
+ }
+
+ onRedo() {
+ if (!this.history.canRedo()) {
+ return;
+ }
+
+ this.applyHistory(this.history.redo(this.imageCanvas));
+ this.deriveButtonStates();
+ }
+
+ applyHistory(record) {
+ if (!record) {
+ return;
+ }
+ switch (record.recordType) {
+ case RecordType.DIFF:
+ this.applyDiff(record.area, record.canvas);
+ break;
+ case RecordType.FRAME:
+ this.applyFrame(record.area, record.canvas);
+ break;
+ default:
+ break;
+ }
+ }
+
onClickClear() {
- this.setState({tool: this.state.tool});
+ this.setState({
+ canvasWidth: Math.floor(this.props.clip.image.dimensions.x),
+ canvasHeight: Math.floor(this.props.clip.image.dimensions.y)
+ });
+ this.history = new EditorHistory(this.devicePixelRatio);
+ this.deriveButtonStates();
this.imageContext.clearRect(0, 0, this.imageCanvas.width, this.imageCanvas.height);
- this.canvasHeight = this.props.clip.image.dimensions.y;
- this.canvasWidth = this.props.clip.image.dimensions.x;
this.renderImage();
sendEvent("clear-select", "annotation-toolbar");
}
@@ -214,7 +295,7 @@ exports.Editor = class Editor extends React.Component {
}
}
- const dimensions = {x: this.canvasWidth, y: this.canvasHeight};
+ const dimensions = {x: this.state.canvasWidth, y: this.state.canvasHeight};
this.props.onClickSave(dataUrl, dimensions);
sendEvent("save", "annotation-toolbar");
}
diff --git a/static/css/frame.scss b/static/css/frame.scss
index 22946c399e..c47f95ae31 100644
--- a/static/css/frame.scss
+++ b/static/css/frame.scss
@@ -334,14 +334,53 @@
}
}
+.undo-button {
+ background: url("../img/annotation-undo.svg");
+ transform: scale(-1, 1);
+
+ &.inactive {
+ background: url("../img/annotation-undo-inactive.svg") center no-repeat;
+ }
+}
+
+.redo-button {
+ background-image: url("../img/annotation-redo.svg");
+
+ &.inactive {
+ background: url("../img/annotation-redo-inactive.svg") center no-repeat;
+ }
+}
+
.clear-button {
background-image: url("../img/reset.svg");
+
+ &.inactive {
+ background: url("../img/reset-inactive.svg") center no-repeat;
+ }
+}
+
+.undo-button,
+.redo-button,
+.clear-button {
background-repeat: no-repeat;
background-position: center;
&:hover {
background-color: $light-hover;
}
+ &.inactive {
+ &:hover {
+ background-color: #fff;
+ cursor: default;
+ }
+ }
+}
+
+.annotation-divider {
+ border-right: 1px solid #989a9c;
+ margin: auto auto;
+ width: 1px;
+ height: 28px;
}
html {
@@ -408,10 +447,6 @@ body {
box-shadow: rgba(0, 0, 0, 0.15) 0 2px 4px;
border-radius: $border-radius;
height: 50px;
-
- & .button:first-child {
- margin-right: 0;
- }
}
.annotation-main-actions {
@@ -483,6 +518,7 @@ body {
#color-button-container {
min-width: 40px;
+ margin-left: 5px;
}
#color-button-highlight {
@@ -492,6 +528,7 @@ body {
height: 40px;
border-radius: 3px;
position: absolute;
+ left: 170.5px;
background-color: #ededf0;
}
@@ -500,7 +537,7 @@ body {
position: absolute;
height: 22px;
width: 22px;
- margin: 13.5px 10px 0 9.5px;
+ margin: 13.5px 10px 0 4.5px;
z-index: 2;
&:hover {
@@ -519,6 +556,7 @@ body {
width: 160px;
height: 160px;
position: absolute;
+ left: 170.5px;
background: $light-default;
border: 1px solid $light-border;
border-radius: 10px;
diff --git a/static/img/annotation-redo-inactive.svg b/static/img/annotation-redo-inactive.svg
new file mode 100644
index 0000000000..d4bd4b6ac8
--- /dev/null
+++ b/static/img/annotation-redo-inactive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/img/annotation-redo.svg b/static/img/annotation-redo.svg
new file mode 100644
index 0000000000..cbcd7b5472
--- /dev/null
+++ b/static/img/annotation-redo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/img/annotation-undo-inactive.svg b/static/img/annotation-undo-inactive.svg
new file mode 100644
index 0000000000..d4bd4b6ac8
--- /dev/null
+++ b/static/img/annotation-undo-inactive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/img/annotation-undo.svg b/static/img/annotation-undo.svg
new file mode 100644
index 0000000000..cbcd7b5472
--- /dev/null
+++ b/static/img/annotation-undo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/img/reset-inactive.svg b/static/img/reset-inactive.svg
new file mode 100644
index 0000000000..05d83ac724
--- /dev/null
+++ b/static/img/reset-inactive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file