├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── demo.html ├── demo2.html ├── index.js ├── package.json ├── test.html ├── test.web.js └── undo-canvas.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [magicien] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | tmp 3 | *~ 4 | *.swp 5 | node_modules 6 | build/ 7 | coverage* 8 | .nyc_output 9 | npm-debug.log 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 magicien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # undo-canvas 2 | Add undo/redo functions to CanvasRenderingContext2D 3 | 4 | [Online Demo 1](https://magicien.github.io/undo-canvas/demo.html) / [Demo 2](https://magicien.github.io/undo-canvas/demo2.html) 5 | 6 | ``` 7 | 8 | 26 | ``` 27 | 28 | ## Install 29 | 30 | ### Node 31 | ``` 32 | npm install --save undo-canvas 33 | ``` 34 | 35 | ### Browser 36 | ``` 37 | 38 | ``` 39 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | undo-canvas demo 6 | 7 | 67 | 68 | 69 |

Undo/Redo 100,000 line strokes

70 |
71 |
72 | 73 | 74 |
75 | 76 |
77 | 78 | 79 | -------------------------------------------------------------------------------- /demo2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | undo-canvas demo 2 6 | 7 | 164 | 165 | 166 |

Undo/Redo Tags Demo

167 | Draw a line on mouse drag 168 |
169 |
170 | 171 | 172 | 173 | 174 |
175 | 176 |
177 | 178 | 179 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const resetObject = require('reset-object') 2 | 3 | const ignoreTriggers = [ 4 | 'canvas', 5 | 'constructor', 6 | 'createImageData', 7 | 'createLinearGradient', 8 | 'createPattern', 9 | 'createRadialGradient', 10 | 'getImageData', 11 | 'getLineDash', 12 | 'isPointInPath', 13 | 'isPointInStroke', 14 | 'measureText', 15 | 'scrollPathIntoView' 16 | ] 17 | 18 | const commitTriggers = [ 19 | 'clearRect', 20 | 'drawFocusIfNeeded', 21 | 'drawImage', 22 | 'fill', 23 | 'fillRect', 24 | 'fillText', 25 | 'putImageData', 26 | 'stroke', 27 | 'strokeRect', 28 | 'strokeText' 29 | ] 30 | 31 | class CheckPoint { 32 | constructor(context, redo) { 33 | this.parameters = null 34 | this.imageData = null 35 | this.redo = redo 36 | 37 | this.getContextParameters(context) 38 | this.getImageData(context) 39 | } 40 | 41 | getImageData(context) { 42 | const prop = Object.getOwnPropertyDescriptor(context.constructor.prototype, 'getImageData') 43 | this.imageData = prop.value.bind(context)(0, 0, context.canvas.width, context.canvas.height) 44 | } 45 | 46 | putImageData(context) { 47 | context.canvas.width = this.imageData.width 48 | context.canvas.height = this.imageData.height 49 | 50 | const prop = Object.getOwnPropertyDescriptor(context.constructor.prototype, 'putImageData') 51 | prop.value.bind(context)(this.imageData, 0, 0) 52 | } 53 | 54 | getContextParameters(context) { 55 | const names = Object.getOwnPropertyNames(context.constructor.prototype) 56 | const params = {} 57 | for(const name of names){ 58 | if(ignoreTriggers.indexOf(name) !== -1){ 59 | continue 60 | } 61 | const prop = Object.getOwnPropertyDescriptor(context.constructor.prototype, name) 62 | if(prop.get && prop.set){ 63 | params[name] = prop.get.bind(context)() 64 | } 65 | } 66 | this.parameters = params 67 | } 68 | 69 | setContextParameters(context) { 70 | const src = this.parameters 71 | const dst = context 72 | 73 | const keys = Object.keys(src) 74 | for(const key of keys){ 75 | const prop = Object.getOwnPropertyDescriptor(context.constructor.prototype, key) 76 | prop.set.bind(context)(src[key]) 77 | } 78 | } 79 | 80 | apply(context) { 81 | this.putImageData(context) 82 | this.setContextParameters(context) 83 | context._undodata.cost = 0 84 | } 85 | 86 | serialize() { 87 | const data = {} 88 | data.p = this.parameters 89 | data.w = this.imageData.width 90 | data.h = this.imageData.height 91 | data.d = this.imageData.data 92 | } 93 | 94 | deserialize(data) { 95 | this.parameters = data.p 96 | this.imageData = new ImageData(data.d, data.w, data.h) 97 | this.redo = null 98 | } 99 | } 100 | 101 | class RedoLog { 102 | constructor(commands = [], no = null) { 103 | this.no = no 104 | this.commands = commands 105 | this.cost = this.calcCost() 106 | } 107 | 108 | apply(context) { 109 | for(const command of this.commands){ 110 | command.apply(context) 111 | } 112 | context._undodata.cost += this.cost 113 | } 114 | 115 | calcCost() { 116 | let cost = 0 117 | for(const command of this.commands){ 118 | cost += command.cost 119 | } 120 | return cost 121 | } 122 | 123 | serialize(funcs) { 124 | const data = [] 125 | for(const command of this.commands){ 126 | data.push(command.serialize(funcs)) 127 | } 128 | return data 129 | } 130 | 131 | static deserialize(data, no, funcs) { 132 | const commands = [] 133 | for(const d of data){ 134 | const command = CommandLog.deserialize(d, funcs) 135 | commands.push(command) 136 | } 137 | return new RedoLog(commands, no) 138 | } 139 | } 140 | 141 | class CommandLog { 142 | constructor(func, args, cost = 1) { 143 | this.func = func 144 | this.args = args 145 | this.cost = cost 146 | } 147 | 148 | apply(context) { 149 | this.func.bind(context)(...this.args) 150 | } 151 | 152 | serialize(funcs) { 153 | let index = funcs.indexOf(this.func) 154 | if(index == -1){ 155 | funcs.push(this.func) 156 | index = funcs.length - 1 157 | } 158 | return { 159 | f: index, 160 | a: serializeData(args) 161 | } 162 | } 163 | 164 | static deserialize(data, funcs) { 165 | const func = funcs[data.f] 166 | const args = deserializeData(data.a) 167 | const command = new CommandLog(func, args) 168 | return command 169 | } 170 | } 171 | 172 | function undo(step = 1) { 173 | if(step < 1){ 174 | return 175 | } 176 | if(this._undodata.commands.length > 0){ 177 | commit(this) 178 | } 179 | let redoNo = this._undodata.current.no - step 180 | if(redoNo < 0){ 181 | redoNo = 0 182 | } 183 | const cp = getLatestCheckpoint(this, redoNo) 184 | cp.apply(this) 185 | this._undodata.current = cp.redo 186 | 187 | let redo = cp.redo.next 188 | while(redo && redo.no <= redoNo){ 189 | redo.apply(this) 190 | this._undodata.current = redo 191 | redo = redo.next 192 | } 193 | } 194 | 195 | function redo(step = 1) { 196 | if(step < 1){ 197 | return 198 | } 199 | let redoNo = this._undodata.current.no + step 200 | const latestNo = this._undodata.redos[this._undodata.redos.length-1].no 201 | if(redoNo > latestNo){ 202 | redoNo = latestNo 203 | } 204 | const currentCp = getLatestCheckpoint(this, this._undodata.current.no) 205 | let redo = this._undodata.current 206 | 207 | const cp = getLatestCheckpoint(this, redoNo) 208 | if(currentCp !== cp){ 209 | cp.apply(this) 210 | redo = cp.redo 211 | } 212 | while(redo && redo.no <= redoNo){ 213 | redo.apply(this) 214 | this._undodata.current = redo 215 | redo = redo.next 216 | } 217 | } 218 | 219 | function undoTag(name = /.*/, step = 1) { 220 | if(step < 1){ 221 | return 222 | } 223 | const current = this._undodata.current 224 | let tags 225 | if(name instanceof RegExp){ 226 | tags = this._undodata.tags.filter(tag => tag.no < current.no && name.test(tag.name)) 227 | }else{ 228 | tags = this._undodata.tags.filter(tag => tag.no < current.no && tag.name == name) 229 | } 230 | let index = tags.length - step 231 | if(index < 0){ 232 | return 233 | } 234 | this.currentHistoryNo = tags[index].no 235 | } 236 | 237 | function redoTag(name = /.*/, step = 1) { 238 | if(step < 1){ 239 | return 240 | } 241 | const current = this._undodata.current 242 | let tags 243 | if(name instanceof RegExp){ 244 | tags = this._undodata.tags.filter(tag => tag.no > current.no && name.test(tag.name)) 245 | }else{ 246 | tags = this._undodata.tags.filter(tag => tag.no > current.no && tag.name == name) 247 | } 248 | if(tags.length <= step - 1){ 249 | return 250 | } 251 | this.currentHistoryNo = tags[step - 1].no 252 | } 253 | 254 | function putTag(name = '') { 255 | const newData = { 256 | no: this.currentHistoryNo, 257 | name: name 258 | } 259 | const tags = this._undodata.tags 260 | for(let i=tags.length-1; i>=0; i--){ 261 | if(tags[i].no <= newData.no){ 262 | tags.splice(i+1, 0, newData) 263 | return 264 | } 265 | } 266 | tags.push(newData) 267 | } 268 | 269 | function serializeData(obj) { 270 | } 271 | 272 | function deserializeData(data) { 273 | } 274 | 275 | function serialize() { 276 | const funcs = [] 277 | const data = {context: {}, oldest: 0, current: 0, funcs: [], redos: [], tags: []} 278 | 279 | data.context = this._undodata.checkpoints[0].serialize() 280 | data.oldest = this._undodata.oldestHistoryNo 281 | data.current = this._undodata.currentHistoryNo 282 | 283 | const redos = [] 284 | for(const redo of this._undodata.redos){ 285 | redos.push(redo.serialize(funcs)) 286 | } 287 | data.redos = redos 288 | 289 | for(const func of funcs){ 290 | data.funcs.push(func.name) 291 | } 292 | 293 | const tags = [] 294 | for(const tag of this._undodata.tags){ 295 | tags.push({ 296 | n: tag.name, 297 | r: tag.no 298 | }) 299 | } 300 | 301 | return data 302 | } 303 | 304 | function deserialize(data) { 305 | } 306 | 307 | function getCurrentHistoryNo() { 308 | return this._undodata.current.no 309 | } 310 | 311 | function setCurrentHistoryNo(value) { 312 | const step = value - this._undodata.current.no 313 | if(step > 0){ 314 | this.redo(step) 315 | }else if(step < 0){ 316 | this.undo(-step) 317 | } 318 | } 319 | 320 | function getLatestCheckpoint(obj, no) { 321 | const cps = obj._undodata.checkpoints 322 | for(let i=cps.length-1; i>=0; i--){ 323 | const cp = cps[i] 324 | if(cp.redo.no <= no){ 325 | return cp 326 | } 327 | } 328 | return null 329 | } 330 | 331 | function getLatestRedo(obj) { 332 | const redoLen = obj._undodata.redos.length 333 | return obj._undodata.redos[redoLen - 1] 334 | } 335 | 336 | function recalcCost(obj) { 337 | const lastCp = obj._undodata.checkpoints[obj._undodata.checkpoints.length - 1] 338 | let redo = lastCp.redo.next 339 | let cost = 0 340 | while(redo){ 341 | cost += redo.cost 342 | redo = redo.next 343 | } 344 | obj._undodata.cost = cost 345 | } 346 | 347 | function deleteFutureData(obj) { 348 | const current = obj._undodata.current 349 | const currentNo = current.no 350 | 351 | // delete redos 352 | const latestRedo = getLatestRedo(obj) 353 | const numRedos = latestRedo.no - current.no 354 | if(numRedos <= 0){ 355 | return 356 | } 357 | obj._undodata.redos.length = obj._undodata.redos.length - numRedos 358 | current.next = null 359 | 360 | // delete checkpoints 361 | const checkpoints = obj._undodata.checkpoints 362 | let i = checkpoints.length - 1 363 | for(; i>=0; i--){ 364 | if(checkpoints[i].redo.no <= currentNo){ 365 | break 366 | } 367 | } 368 | checkpoints.length = i + 1 369 | 370 | // delete tags 371 | const tags = obj._undodata.tags 372 | i = tags.length - 1 373 | for(; i>=0; i--){ 374 | if(tags[i].no <= currentNo){ 375 | break 376 | } 377 | } 378 | tags.length = i + 1 379 | 380 | recalcCost(obj) 381 | } 382 | 383 | function addCommand(obj, command) { 384 | obj._undodata.commands.push(command) 385 | } 386 | 387 | function addRedo(obj, redoLog) { 388 | const current = obj._undodata.current 389 | redoLog.no = current.no + 1 390 | current.next = redoLog 391 | obj._undodata.redos.push(redoLog) 392 | obj._undodata.current = redoLog 393 | obj._undodata.cost += redoLog.cost 394 | 395 | if(obj._undodata.cost > obj._undodata.cpThreshold){ 396 | const cp = new CheckPoint(obj, redoLog) 397 | obj._undodata.checkpoints.push(cp) 398 | obj._undodata.cost = 0 399 | } 400 | } 401 | 402 | const commandCost = { 403 | 'putImageData': 1000, 404 | 'drawImage': 1000 405 | } 406 | 407 | function recordCommand(obj, func, args) { 408 | deleteFutureData(obj) 409 | const cost = commandCost[func.name] || 1 410 | const command = new CommandLog(func, args, cost) 411 | addCommand(obj, command) 412 | } 413 | 414 | function commit(obj) { 415 | const redoLog = new RedoLog(obj._undodata.commands) 416 | obj._undodata.commands = [] 417 | addRedo(obj, redoLog) 418 | } 419 | 420 | function hookAccessor(obj, propertyName) { 421 | const desc = Object.getOwnPropertyDescriptor(obj.constructor.prototype, propertyName) 422 | Object.defineProperty(obj, propertyName, { 423 | set: (newValue) => { 424 | recordCommand(obj, desc.set, [newValue]) 425 | desc.set.bind(obj)(newValue) 426 | }, 427 | get: desc.get ? desc.get.bind(obj) : () => {}, 428 | enumerable: true, 429 | configurable: true 430 | }) 431 | } 432 | 433 | function hookFunction(obj, propertyName, needsCommit) { 434 | const desc = Object.getOwnPropertyDescriptor(obj.constructor.prototype, propertyName) 435 | const orgFunc = desc.value.bind(obj) 436 | obj[propertyName] = (...args) => { 437 | recordCommand(obj, desc.value, args) 438 | if(needsCommit){ 439 | commit(obj) 440 | } 441 | orgFunc(...args) 442 | } 443 | } 444 | 445 | function hook(obj, propertyName, needsCommit) { 446 | const desc = Object.getOwnPropertyDescriptor(obj.constructor.prototype, propertyName) 447 | if(typeof desc === 'undefined'){ 448 | return 449 | } 450 | 451 | if(!desc.configurable){ 452 | console.error(propertyName + ' is not configurable') 453 | return 454 | } 455 | 456 | if(typeof desc.set !== 'undefined'){ 457 | hookAccessor(obj, propertyName, desc) 458 | }else if(typeof desc.get !== 'undefined'){ 459 | // read-only: nothing to do 460 | }else{ 461 | hookFunction(obj, propertyName, needsCommit) 462 | } 463 | } 464 | 465 | function isContext2D(context) { 466 | return context instanceof CanvasRenderingContext2D 467 | } 468 | 469 | function addUndoProperties(context) { 470 | context.undo = undo.bind(context) 471 | context.redo = redo.bind(context) 472 | context.undoTag = undoTag.bind(context) 473 | context.redoTag = redoTag.bind(context) 474 | context.putTag = putTag.bind(context) 475 | context.serialize = serialize.bind(context) 476 | context.deserialize = deserialize.bind(context) 477 | Object.defineProperty(context, 'currentHistoryNo', { 478 | enumerable: true, 479 | configurable: true, 480 | get: getCurrentHistoryNo.bind(context), 481 | set: setCurrentHistoryNo.bind(context) 482 | }) 483 | Object.defineProperty(context, 'oldestHistoryNo', { 484 | enumerable: false, 485 | configurable: true, 486 | get: () => context._undodata.redos[0].no 487 | }) 488 | Object.defineProperty(context, 'newestHistoryNo', { 489 | enumerable: false, 490 | configurable: true, 491 | get: () => context._undodata.redos[context._undodata.redos.length - 1].no 492 | }) 493 | 494 | const redoLog = new RedoLog([], 0) 495 | const cp = new CheckPoint(context, redoLog) 496 | const data = { 497 | checkpoints: [cp], 498 | redos: [redoLog], 499 | tags: [], 500 | current: redoLog, 501 | commands: [], 502 | cost: 0, 503 | cpThreshold: 5000 504 | } 505 | 506 | Object.defineProperty(context, '_undodata', { 507 | enumerable: false, 508 | configurable: true, 509 | value: data 510 | }) 511 | } 512 | 513 | function deleteUndoProperties(context) { 514 | delete context.undo 515 | delete context.redo 516 | delete context.undoTag 517 | delete context.redoTag 518 | delete context.putTag 519 | delete context._undodata 520 | } 521 | 522 | function enableUndo(context, options = {}) { 523 | if(!isContext2D(context)){ 524 | throw 'enableUndo: context is not instance of CanvasRenderingContext2D' 525 | } 526 | 527 | const names = Object.getOwnPropertyNames(context.constructor.prototype) 528 | for(const name of names){ 529 | if(ignoreTriggers.indexOf(name) === -1){ 530 | const needsCommit = commitTriggers.indexOf(name) >= 0 531 | hook(context, name, needsCommit) 532 | } 533 | } 534 | addUndoProperties(context) 535 | } 536 | 537 | function disableUndo(context) { 538 | if(!isContext2D(context)){ 539 | throw 'disableUndo: context is not instance of CanvasRenderingContext2D' 540 | } 541 | deleteUndoProperties(context) 542 | resetObject(context) 543 | } 544 | 545 | module.exports = { enableUndo, disableUndo } 546 | 547 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "undo-canvas", 3 | "version": "0.1.3", 4 | "description": "Adds undo/redo functions to CanvasRenderingContext2D", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "uglifyjs index.js -c | browserify - --standalone UndoCanvas -o undo-canvas.js", 8 | "test": "open test.html" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/magicien/undo-canvas.git" 13 | }, 14 | "keywords": [ 15 | "canvas" 16 | ], 17 | "author": "magicien", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/magicien/undo-canvas/issues" 21 | }, 22 | "homepage": "https://github.com/magicien/undo-canvas#readme", 23 | "devDependencies": { 24 | "browserify": "^14.4.0", 25 | "chai": "^4.1.2", 26 | "mocha": "^3.5.0", 27 | "uglify-es": "^3.0.28" 28 | }, 29 | "dependencies": { 30 | "reset-object": "^0.1.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | undo-canvas test 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 16 | 17 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test.web.js: -------------------------------------------------------------------------------- 1 | const canvas = document.createElement('canvas') 2 | const context = canvas.getContext('2d') 3 | 4 | const getCommandLength = (context) => { 5 | return context._undodata.commands.length 6 | } 7 | const getRedoLength = (context) => { 8 | return context._undodata.redos.length 9 | } 10 | const getCheckpointLength = (context) => { 11 | return context._undodata.checkpoints.length 12 | } 13 | 14 | describe('undoCanvas', () => { 15 | describe('enableUndo', () => { 16 | it('should add undo/redo functions', () => { 17 | expect(context).to.not.respondTo('undo') 18 | expect(context).to.not.respondTo('redo') 19 | 20 | UndoCanvas.enableUndo(context) 21 | 22 | expect(context).to.respondTo('undo') 23 | expect(context).to.respondTo('redo') 24 | }) 25 | 26 | it('should record fillStyle', () => { 27 | expect(getCommandLength(context)).to.equal(0) 28 | context.fillStyle = '#aabbcc' 29 | expect(getCommandLength(context)).to.equal(1) 30 | expect(context._undodata.commands[0].args[0]).to.deep.equal('#aabbcc') 31 | }) 32 | 33 | it('should record font', () => { 34 | context.font = 'Arial' 35 | expect(getCommandLength(context)).to.equal(2) 36 | expect(context._undodata.commands[1].args[0]).to.deep.equal('Arial') 37 | }) 38 | 39 | it('should record globalAlpha', () => { 40 | context.globalAlpha = 0.8 41 | expect(getCommandLength(context)).to.equal(3) 42 | expect(context._undodata.commands[2].args[0]).to.deep.equal(0.8) 43 | }) 44 | 45 | it('should record globalCompositeOperation', () => { 46 | context.globalCompositeOperation = 'source-in' 47 | expect(getCommandLength(context)).to.equal(4) 48 | expect(context._undodata.commands[3].args[0]).to.deep.equal('source-in') 49 | }) 50 | 51 | it('should record lineCap', () => { 52 | }) 53 | 54 | it('should record lineDashoffset', () => { 55 | }) 56 | 57 | it('should record lineJoin', () => { 58 | }) 59 | 60 | it('should record lineWidth', () => { 61 | }) 62 | 63 | it('should record miterLimit', () => { 64 | }) 65 | 66 | it('should record shadowBlur', () => { 67 | }) 68 | 69 | it('should record shadowColor', () => { 70 | }) 71 | 72 | it('should record shadowOffsetX', () => { 73 | }) 74 | 75 | it('should record shadowOffsetY', () => { 76 | }) 77 | 78 | it('should record strokeStyle', () => { 79 | }) 80 | 81 | it('should record textAlign', () => { 82 | }) 83 | 84 | it('should record textBaseline', () => { 85 | }) 86 | 87 | it('should record beginPath()', () => { 88 | }) 89 | 90 | it('should record arc()', () => { 91 | }) 92 | 93 | it('should record arcTo()', () => { 94 | }) 95 | 96 | it('should record bezierCurveTo()', () => { 97 | }) 98 | 99 | it('should record ellipse()', () => { 100 | }) 101 | 102 | it('should record moveTo()', () => { 103 | }) 104 | 105 | it('should record lineTo()', () => { 106 | }) 107 | 108 | it('should record quadraticCurveTo()', () => { 109 | }) 110 | 111 | it('should record rect()', () => { 112 | }) 113 | 114 | it('should record closePath()', () => { 115 | }) 116 | 117 | it('should record clip()', () => { 118 | }) 119 | 120 | it('should record and commit clearRect()', () => { 121 | }) 122 | 123 | it('should ignore createImageData()', () => { 124 | }) 125 | 126 | it('should ignore createLinearGradient()', () => { 127 | }) 128 | 129 | it('should ignore createPattern()', () => { 130 | }) 131 | 132 | it('should ignore createRadialGradient()', () => { 133 | }) 134 | 135 | it('should record and commit drawFocusIfNeeded()', () => { 136 | }) 137 | 138 | it('should record and commit drawImage()', () => { 139 | }) 140 | 141 | it('should record and commit fill()', () => { 142 | }) 143 | 144 | it('should record and commit fillRect()', () => { 145 | }) 146 | 147 | it('should record and commit fillText()', () => { 148 | }) 149 | 150 | it('should ignore getImageData()', () => { 151 | }) 152 | 153 | it('should ignore getLineDash()', () => { 154 | }) 155 | 156 | it('should ignore isPointInPath()', () => { 157 | }) 158 | 159 | it('should ignore isPointInStroke()', () => { 160 | }) 161 | 162 | it('should ignore measureText()', () => { 163 | }) 164 | 165 | it('should record and commit putImageData()', () => { 166 | }) 167 | 168 | it('should record save()', () => { 169 | }) 170 | 171 | it('should record rotate()', () => { 172 | }) 173 | 174 | it('should record scale()', () => { 175 | }) 176 | 177 | it('should record setLineDash()', () => { 178 | }) 179 | 180 | it('should record setTransform()', () => { 181 | }) 182 | 183 | it('should record and commit stroke()', () => { 184 | }) 185 | 186 | it('should record and commit strokeRect()', () => { 187 | }) 188 | 189 | it('should record and commit strokeText()', () => { 190 | }) 191 | 192 | it('should record transform()', () => { 193 | }) 194 | 195 | it('should record translate()', () => { 196 | }) 197 | 198 | it('should record restore()', () => { 199 | }) 200 | }) 201 | 202 | describe('disableUndo', () => { 203 | it('should remove undo/redo functions', () => { 204 | expect(context).to.respondTo('undo') 205 | expect(context).to.respondTo('redo') 206 | 207 | UndoCanvas.disableUndo(context) 208 | 209 | expect(context).to.not.respondTo('undo') 210 | expect(context).to.not.respondTo('redo') 211 | }) 212 | }) 213 | }) 214 | -------------------------------------------------------------------------------- /undo-canvas.js: -------------------------------------------------------------------------------- 1 | (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.UndoCanvas = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o0&&commit(this);let redoNo=this._undodata.current.no-step;redoNo<0&&(redoNo=0);const cp=getLatestCheckpoint(this,redoNo);cp.apply(this),this._undodata.current=cp.redo;let redo=cp.redo.next;for(;redo&&redo.no<=redoNo;)redo.apply(this),this._undodata.current=redo,redo=redo.next}function redo(step=1){if(step<1)return;let redoNo=this._undodata.current.no+step;const latestNo=this._undodata.redos[this._undodata.redos.length-1].no;redoNo>latestNo&&(redoNo=latestNo);const currentCp=getLatestCheckpoint(this,this._undodata.current.no);let redo=this._undodata.current;const cp=getLatestCheckpoint(this,redoNo);for(currentCp!==cp&&(cp.apply(this),redo=cp.redo);redo&&redo.no<=redoNo;)redo.apply(this),this._undodata.current=redo,redo=redo.next}function undoTag(name=/.*/,step=1){if(step<1)return;const current=this._undodata.current;let tags,index=(tags=name instanceof RegExp?this._undodata.tags.filter(tag=>tag.notag.notag.no>current.no&&name.test(tag.name)):this._undodata.tags.filter(tag=>tag.no>current.no&&tag.name==name)).length<=step-1||(this.currentHistoryNo=tags[step-1].no)}function putTag(name=""){const newData={no:this.currentHistoryNo,name:name},tags=this._undodata.tags;for(let i=tags.length-1;i>=0;i--)if(tags[i].no<=newData.no)return void tags.splice(i+1,0,newData);tags.push(newData)}function serializeData(obj){}function deserializeData(data){}function serialize(){const funcs=[],data={context:{},oldest:0,current:0,funcs:[],redos:[],tags:[]};data.context=this._undodata.checkpoints[0].serialize(),data.oldest=this._undodata.oldestHistoryNo,data.current=this._undodata.currentHistoryNo;const redos=[];for(const redo of this._undodata.redos)redos.push(redo.serialize(funcs));data.redos=redos;for(const func of funcs)data.funcs.push(func.name);const tags=[];for(const tag of this._undodata.tags)tags.push({n:tag.name,r:tag.no});return data}function deserialize(data){}function getCurrentHistoryNo(){return this._undodata.current.no}function setCurrentHistoryNo(value){const step=value-this._undodata.current.no;step>0?this.redo(step):step<0&&this.undo(-step)}function getLatestCheckpoint(obj,no){const cps=obj._undodata.checkpoints;for(let i=cps.length-1;i>=0;i--){const cp=cps[i];if(cp.redo.no<=no)return cp}return null}function getLatestRedo(obj){const redoLen=obj._undodata.redos.length;return obj._undodata.redos[redoLen-1]}function recalcCost(obj){let redo=obj._undodata.checkpoints[obj._undodata.checkpoints.length-1].redo.next,cost=0;for(;redo;)cost+=redo.cost,redo=redo.next;obj._undodata.cost=cost}function deleteFutureData(obj){const current=obj._undodata.current,currentNo=current.no,numRedos=getLatestRedo(obj).no-current.no;if(numRedos<=0)return;obj._undodata.redos.length=obj._undodata.redos.length-numRedos,current.next=null;const checkpoints=obj._undodata.checkpoints;let i=checkpoints.length-1;for(;i>=0&&!(checkpoints[i].redo.no<=currentNo);i--);checkpoints.length=i+1;const tags=obj._undodata.tags;for(i=tags.length-1;i>=0&&!(tags[i].no<=currentNo);i--);tags.length=i+1,recalcCost(obj)}function addCommand(obj,command){obj._undodata.commands.push(command)}function addRedo(obj,redoLog){const current=obj._undodata.current;if(redoLog.no=current.no+1,current.next=redoLog,obj._undodata.redos.push(redoLog),obj._undodata.current=redoLog,obj._undodata.cost+=redoLog.cost,obj._undodata.cost>obj._undodata.cpThreshold){const cp=new CheckPoint(obj,redoLog);obj._undodata.checkpoints.push(cp),obj._undodata.cost=0}}function recordCommand(obj,func,args){deleteFutureData(obj);const cost=commandCost[func.name]||1;addCommand(obj,new CommandLog(func,args,cost))}function commit(obj){const redoLog=new RedoLog(obj._undodata.commands);obj._undodata.commands=[],addRedo(obj,redoLog)}function hookAccessor(obj,propertyName){const desc=Object.getOwnPropertyDescriptor(obj.constructor.prototype,propertyName);Object.defineProperty(obj,propertyName,{set:newValue=>{recordCommand(obj,desc.set,[newValue]),desc.set.bind(obj)(newValue)},get:desc.get?desc.get.bind(obj):()=>{},enumerable:!0,configurable:!0})}function hookFunction(obj,propertyName,needsCommit){const desc=Object.getOwnPropertyDescriptor(obj.constructor.prototype,propertyName),orgFunc=desc.value.bind(obj);obj[propertyName]=((...args)=>{recordCommand(obj,desc.value,args),needsCommit&&commit(obj),orgFunc(...args)})}function hook(obj,propertyName,needsCommit){const desc=Object.getOwnPropertyDescriptor(obj.constructor.prototype,propertyName);void 0!==desc&&(desc.configurable?void 0!==desc.set?hookAccessor(obj,propertyName,desc):void 0!==desc.get||hookFunction(obj,propertyName,needsCommit):console.error(propertyName+" is not configurable"))}function isContext2D(context){return context instanceof CanvasRenderingContext2D}function addUndoProperties(context){context.undo=undo.bind(context),context.redo=redo.bind(context),context.undoTag=undoTag.bind(context),context.redoTag=redoTag.bind(context),context.putTag=putTag.bind(context),context.serialize=serialize.bind(context),context.deserialize=deserialize.bind(context),Object.defineProperty(context,"currentHistoryNo",{enumerable:!0,configurable:!0,get:getCurrentHistoryNo.bind(context),set:setCurrentHistoryNo.bind(context)}),Object.defineProperty(context,"oldestHistoryNo",{enumerable:!1,configurable:!0,get:()=>context._undodata.redos[0].no}),Object.defineProperty(context,"newestHistoryNo",{enumerable:!1,configurable:!0,get:()=>context._undodata.redos[context._undodata.redos.length-1].no});const redoLog=new RedoLog([],0),data={checkpoints:[new CheckPoint(context,redoLog)],redos:[redoLog],tags:[],current:redoLog,commands:[],cost:0,cpThreshold:5e3};Object.defineProperty(context,"_undodata",{enumerable:!1,configurable:!0,value:data})}function deleteUndoProperties(context){delete context.undo,delete context.redo,delete context.undoTag,delete context.redoTag,delete context.putTag,delete context._undodata}function enableUndo(context,options={}){if(!isContext2D(context))throw"enableUndo: context is not instance of CanvasRenderingContext2D";const names=Object.getOwnPropertyNames(context.constructor.prototype);for(const name of names)-1===ignoreTriggers.indexOf(name)&&hook(context,name,commitTriggers.indexOf(name)>=0);addUndoProperties(context)}function disableUndo(context){if(!isContext2D(context))throw"disableUndo: context is not instance of CanvasRenderingContext2D";deleteUndoProperties(context),resetObject(context)}const resetObject=require("reset-object"),ignoreTriggers=["canvas","constructor","createImageData","createLinearGradient","createPattern","createRadialGradient","getImageData","getLineDash","isPointInPath","isPointInStroke","measureText","scrollPathIntoView"],commitTriggers=["clearRect","drawFocusIfNeeded","drawImage","fill","fillRect","fillText","putImageData","stroke","strokeRect","strokeText"];class CheckPoint{constructor(context,redo){this.parameters=null,this.imageData=null,this.redo=redo,this.getContextParameters(context),this.getImageData(context)}getImageData(context){const prop=Object.getOwnPropertyDescriptor(context.constructor.prototype,"getImageData");this.imageData=prop.value.bind(context)(0,0,context.canvas.width,context.canvas.height)}putImageData(context){context.canvas.width=this.imageData.width,context.canvas.height=this.imageData.height,Object.getOwnPropertyDescriptor(context.constructor.prototype,"putImageData").value.bind(context)(this.imageData,0,0)}getContextParameters(context){const names=Object.getOwnPropertyNames(context.constructor.prototype),params={};for(const name of names){if(-1!==ignoreTriggers.indexOf(name))continue;const prop=Object.getOwnPropertyDescriptor(context.constructor.prototype,name);prop.get&&prop.set&&(params[name]=prop.get.bind(context)())}this.parameters=params}setContextParameters(context){const src=this.parameters,keys=Object.keys(src);for(const key of keys)Object.getOwnPropertyDescriptor(context.constructor.prototype,key).set.bind(context)(src[key])}apply(context){this.putImageData(context),this.setContextParameters(context),context._undodata.cost=0}serialize(){const data={};data.p=this.parameters,data.w=this.imageData.width,data.h=this.imageData.height,data.d=this.imageData.data}deserialize(data){this.parameters=data.p,this.imageData=new ImageData(data.d,data.w,data.h),this.redo=null}}class RedoLog{constructor(commands=[],no=null){this.no=no,this.commands=commands,this.cost=this.calcCost()}apply(context){for(const command of this.commands)command.apply(context);context._undodata.cost+=this.cost}calcCost(){let cost=0;for(const command of this.commands)cost+=command.cost;return cost}serialize(funcs){const data=[];for(const command of this.commands)data.push(command.serialize(funcs));return data}static deserialize(data,no,funcs){const commands=[];for(const d of data){const command=CommandLog.deserialize(d,funcs);commands.push(command)}return new RedoLog(commands,no)}}class CommandLog{constructor(func,args,cost=1){this.func=func,this.args=args,this.cost=cost}apply(context){this.func.bind(context)(...this.args)}serialize(funcs){let index=funcs.indexOf(this.func);return-1==index&&(funcs.push(this.func),index=funcs.length-1),{f:index,a:serializeData(args)}}static deserialize(data,funcs){const func=funcs[data.f],args=deserializeData(data.a);return new CommandLog(func,args)}}const commandCost={putImageData:1e3,drawImage:1e3};module.exports={enableUndo:enableUndo,disableUndo:disableUndo}; 3 | 4 | },{"reset-object":2}],2:[function(require,module,exports){ 5 | function setValues(src, dst, finishedKeys) { 6 | const keys = Object.getOwnPropertyNames(src) 7 | for(const key of keys){ 8 | if(finishedKeys.indexOf(key) >= 0){ 9 | continue 10 | } 11 | finishedKeys.push(key) 12 | const dstProp = Object.getOwnPropertyDescriptor(dst, key) 13 | if(typeof dstProp !== 'undefined' && !dstProp.configurable){ 14 | continue 15 | } 16 | const srcProp = Object.getOwnPropertyDescriptor(src, key) 17 | if(typeof srcProp.get !== 'undefined' || typeof srcProp.set !== 'undefined'){ 18 | Object.defineProperty(dst, key, srcProp) 19 | }else if(typeof src[key] === 'function'){ 20 | Object.defineProperty(dst, key, srcProp) 21 | }else{ 22 | srcProp.value = dst[key] 23 | Object.defineProperty(dst, key, srcProp) 24 | } 25 | } 26 | } 27 | 28 | function resetObject(obj) { 29 | if(typeof obj.constructor === 'undefined'){ 30 | throw 'resetObject: obj is not an instance object' 31 | } 32 | if(Object.isSealed(obj)){ 33 | throw 'resetObject: obj is sealed' 34 | } 35 | let p = obj.constructor.prototype 36 | let finishedKeys = [] 37 | while(p){ 38 | setValues(p, obj, finishedKeys) 39 | p = Object.getPrototypeOf(p) 40 | 41 | if(p.constructor === Object){ 42 | break 43 | } 44 | } 45 | } 46 | 47 | module.exports = resetObject 48 | 49 | },{}]},{},[1])(1) 50 | }); --------------------------------------------------------------------------------