From b67007586129d7915808a0493f01388a80b7c3db Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Sat, 12 May 2018 19:05:29 -0500 Subject: [PATCH] Add undo & redo for annotations. (#4370, #4371) --- locales/en-US/server.ftl | 4 + server/src/pages/shot/drawing-tool.js | 12 +-- server/src/pages/shot/editor-history.js | 113 ++++++++++++++++++++++++ server/src/pages/shot/editor.js | 113 ++++++++++++++++++++---- static/css/frame.scss | 48 ++++++++-- static/img/annotation-redo-inactive.svg | 1 + static/img/annotation-redo.svg | 1 + static/img/annotation-undo-inactive.svg | 1 + static/img/annotation-undo.svg | 1 + static/img/reset-inactive.svg | 1 + 10 files changed, 268 insertions(+), 27 deletions(-) create mode 100644 server/src/pages/shot/editor-history.js create mode 100644 static/img/annotation-redo-inactive.svg create mode 100644 static/img/annotation-redo.svg create mode 100644 static/img/annotation-undo-inactive.svg create mode 100644 static/img/annotation-undo.svg create mode 100644 static/img/reset-inactive.svg 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 }}> { this.imageCanvas = image; }} - height={this.canvasHeight * this.devicePixelRatio} width={this.canvasWidth * this.devicePixelRatio} - style={{ height: this.canvasHeight, width: this.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