├── .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 | Undo
73 |
74 | Redo
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 | Undo Tag
171 | Undo
172 |
173 | Redo
174 | Redo Tag
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 | });
--------------------------------------------------------------------------------