From 8b6bd09264a927f22ce34571c1dfc7252d2ca8f0 Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Tue, 29 May 2018 17:06:41 -0500 Subject: [PATCH] Add undo and redo to annotations. (#4482) * Add undo & redo for annotations. (#4370, #4371) * Scale the drawing when saving history. (#4370) * Scale once and disable reset button. (#4370, #4371, #4453) * Replace switch with if-else. (#4370, #4371) * Prevent decimals from zoom or DPI scaling. (#4370, #4371) * Use more CSS and less svg files. (#4370, #4371) --- locales/en-US/server.ftl | 4 + server/src/pages/shot/drawing-tool.js | 12 +-- server/src/pages/shot/editor-history.js | 118 ++++++++++++++++++++++++ server/src/pages/shot/editor.js | 116 +++++++++++++++++++---- static/css/frame.scss | 40 +++++++- static/img/annotation-redo.svg | 1 + 6 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 server/src/pages/shot/editor-history.js create mode 100644 static/img/annotation-redo.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..830bea0614 --- /dev/null +++ b/server/src/pages/shot/editor-history.js @@ -0,0 +1,118 @@ +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.scale(devicePixelRatio, devicePixelRatio); + copyContext.drawImage( + canvas, + 0, 0, canvas.width, canvas.height, + 0, 0, area.width, area.height); + return copy; + } + + copy.width = area.width * devicePixelRatio; + copy.height = area.height * devicePixelRatio; + const copyContext = copy.getContext("2d"); + copyContext.scale(devicePixelRatio, devicePixelRatio); + 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..a3ca19b8bd 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() { @@ -40,17 +45,19 @@ exports.Editor = class Editor extends React.Component { } renderCanvas(toolContent) { + const canvasWidth = Math.floor(this.state.canvasWidth * this.devicePixelRatio); + const canvasHeight = Math.floor(this.state.canvasHeight * this.devicePixelRatio); return
+ 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={canvasHeight} width={canvasWidth} + style={{ height: this.state.canvasHeight, width: this.state.canvasWidth }}> {toolContent}
; @@ -91,6 +98,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 +113,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 +135,18 @@ exports.Editor = class Editor extends React.Component { + + + + + + + - +
@@ -155,6 +184,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 +193,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 +210,68 @@ 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; + } + if (record.recordType === RecordType.DIFF) { + this.applyDiff(record.area, record.canvas); + } else { + this.applyFrame(record.area, record.canvas); + } + } + 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 +293,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"); } @@ -255,12 +334,11 @@ exports.Editor = class Editor extends React.Component { } renderImage() { - const imageContext = this.imageCanvas.getContext("2d"); - imageContext.scale(this.devicePixelRatio, this.devicePixelRatio); const img = new Image(); img.crossOrigin = "Anonymous"; const width = this.props.clip.image.dimensions.x; const height = this.props.clip.image.dimensions.y; + const imageContext = this.imageCanvas.getContext("2d"); img.onload = () => { imageContext.drawImage(img, 0, 0, width, height); this.setState({actionsDisabled: false}); @@ -271,6 +349,8 @@ exports.Editor = class Editor extends React.Component { componentDidMount() { document.addEventListener("mouseup", this.onMouseUp); + const imageContext = this.imageCanvas.getContext("2d"); + imageContext.scale(this.devicePixelRatio, this.devicePixelRatio); this.renderImage(); this.setState({ tool: "pen", diff --git a/static/css/frame.scss b/static/css/frame.scss index 5fe110a5b3..596290c945 100644 --- a/static/css/frame.scss +++ b/static/css/frame.scss @@ -330,14 +330,45 @@ } } +.undo-button { + background: url("../img/annotation-redo.svg"); + transform: scale(-1, 1); +} + +.redo-button { + background-image: url("../img/annotation-redo.svg"); +} + .clear-button { background-image: url("../img/reset.svg"); +} + +.undo-button, +.redo-button, +.clear-button { background-repeat: no-repeat; background-position: center; &:hover { background-color: $light-hover; } + &.inactive { + filter: brightness(2.4); + + &:hover, + &:active, + &:focus { + background-color: transparent; + cursor: default; + } + } +} + +.annotation-divider { + border-right: 1px solid #989a9c; + margin: auto auto; + width: 1px; + height: 28px; } html { @@ -404,10 +435,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 { @@ -479,6 +506,7 @@ body { #color-button-container { min-width: 40px; + margin-left: 5px; } #color-button-highlight { @@ -488,6 +516,7 @@ body { height: 40px; border-radius: 3px; position: absolute; + left: 170.5px; background-color: #ededf0; } @@ -496,7 +525,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 { @@ -515,6 +544,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.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