From 4f7b66c0a7987050dd62c50ca18bdb565650a3c4 Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Sat, 12 May 2018 19:05:29 -0500 Subject: [PATCH 1/6] 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 From a50e819acbf951e2b1bc862c818fcfa0b2e6594a Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Thu, 24 May 2018 14:47:19 -0500 Subject: [PATCH 2/6] Scale the drawing when saving history. (#4370) --- server/src/pages/shot/editor-history.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/server/src/pages/shot/editor-history.js b/server/src/pages/shot/editor-history.js index 00db0b91c1..830bea0614 100644 --- a/server/src/pages/shot/editor-history.js +++ b/server/src/pages/shot/editor-history.js @@ -89,13 +89,18 @@ class EditRecord { copy.width = canvas.width; copy.height = canvas.height; const copyContext = copy.getContext("2d"); - copyContext.drawImage(canvas, 0, 0); + 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; - copy.height = area.height; + 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, From 7ecd764c81ce11168f6edb7ac4d8914b2033a329 Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Thu, 24 May 2018 19:11:20 -0500 Subject: [PATCH 3/6] Scale once and disable reset button. (#4370, #4371, #4453) --- server/src/pages/shot/editor.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/pages/shot/editor.js b/server/src/pages/shot/editor.js index 7dba6ca725..40fb532840 100644 --- a/server/src/pages/shot/editor.js +++ b/server/src/pages/shot/editor.js @@ -143,7 +143,8 @@ exports.Editor = class Editor extends React.Component { disabled={!this.state.canRedo} onClick={this.onRedo.bind(this)} title="Redo"> - +
@@ -336,12 +337,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}); @@ -352,6 +352,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", From 10b253d9f63a06eb2a003f6fbbc9ef8d635986f7 Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Fri, 25 May 2018 09:26:54 -0500 Subject: [PATCH 4/6] Replace switch with if-else. (#4370, #4371) --- server/src/pages/shot/editor.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/server/src/pages/shot/editor.js b/server/src/pages/shot/editor.js index 40fb532840..4d9e188939 100644 --- a/server/src/pages/shot/editor.js +++ b/server/src/pages/shot/editor.js @@ -255,15 +255,10 @@ exports.Editor = class Editor extends React.Component { 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; + if (record.recordType === RecordType.DIFF) { + this.applyDiff(record.area, record.canvas); + } else { + this.applyFrame(record.area, record.canvas); } } From e1deac6d3e4503b06ab5571ebf997a8f4f17abb2 Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Fri, 25 May 2018 11:35:34 -0500 Subject: [PATCH 5/6] Prevent decimals from zoom or DPI scaling. (#4370, #4371) --- server/src/pages/shot/editor.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/pages/shot/editor.js b/server/src/pages/shot/editor.js index 4d9e188939..a3ca19b8bd 100644 --- a/server/src/pages/shot/editor.js +++ b/server/src/pages/shot/editor.js @@ -45,6 +45,8 @@ 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
{ this.imageCanvas = image; }} - height={this.state.canvasHeight * this.devicePixelRatio} width={this.state.canvasWidth * this.devicePixelRatio} + height={canvasHeight} width={canvasWidth} style={{ height: this.state.canvasHeight, width: this.state.canvasWidth }}> {toolContent}
From 0b4a2f2eaee25eac23a4fb1f27e69faf94ba2b67 Mon Sep 17 00:00:00 2001 From: Barry Chen Date: Fri, 25 May 2018 15:09:07 -0500 Subject: [PATCH 6/6] Use more CSS and less svg files. (#4370, #4371) --- static/css/frame.scss | 22 +++++++--------------- static/img/annotation-redo-inactive.svg | 1 - static/img/annotation-undo-inactive.svg | 1 - static/img/annotation-undo.svg | 1 - static/img/reset-inactive.svg | 1 - 5 files changed, 7 insertions(+), 19 deletions(-) delete mode 100644 static/img/annotation-redo-inactive.svg delete mode 100644 static/img/annotation-undo-inactive.svg delete mode 100644 static/img/annotation-undo.svg delete mode 100644 static/img/reset-inactive.svg diff --git a/static/css/frame.scss b/static/css/frame.scss index c47f95ae31..5844f5bd7d 100644 --- a/static/css/frame.scss +++ b/static/css/frame.scss @@ -335,28 +335,16 @@ } .undo-button { - background: url("../img/annotation-undo.svg"); + background: url("../img/annotation-redo.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, @@ -369,8 +357,12 @@ background-color: $light-hover; } &.inactive { - &:hover { - background-color: #fff; + filter: brightness(2.4); + + &:hover, + &:active, + &:focus { + background-color: transparent; cursor: default; } } diff --git a/static/img/annotation-redo-inactive.svg b/static/img/annotation-redo-inactive.svg deleted file mode 100644 index d4bd4b6ac8..0000000000 --- a/static/img/annotation-redo-inactive.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/annotation-undo-inactive.svg b/static/img/annotation-undo-inactive.svg deleted file mode 100644 index d4bd4b6ac8..0000000000 --- a/static/img/annotation-undo-inactive.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/annotation-undo.svg b/static/img/annotation-undo.svg deleted file mode 100644 index cbcd7b5472..0000000000 --- a/static/img/annotation-undo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/reset-inactive.svg b/static/img/reset-inactive.svg deleted file mode 100644 index 05d83ac724..0000000000 --- a/static/img/reset-inactive.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file