Skip to content

Commit e31476a

Browse files
committed
Add undo and redo history
1 parent 667971b commit e31476a

File tree

9 files changed

+17891
-13863
lines changed

9 files changed

+17891
-13863
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ extension/toolbar/bundle.css
88
extension/build
99
extension/tuts
1010
VisBug/VisBug.xcodeproj/project.xcworkspace/xcuserdata/argyle.xcuserdatad/UserInterfaceState.xcuserstate
11+
.vscode

app/features/events.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { addToHistory } from "./history";
2+
import { getUniqueSelector } from "../utilities";
3+
4+
const elementSelectorCache = new WeakMap(); // Cache for element selectors
5+
6+
function debounce(func, wait) {
7+
const timeouts = {};
8+
9+
return function (...args) {
10+
const context = this;
11+
const editEvent = args[0];
12+
const element = editEvent.el;
13+
14+
// Use cached selector if available, otherwise compute and cache it
15+
if (!elementSelectorCache.has(element)) {
16+
elementSelectorCache.set(
17+
element,
18+
getUniqueSelector(element)
19+
);
20+
}
21+
const elementSelector = elementSelectorCache.get(element);
22+
23+
if (timeouts[elementSelector]) clearTimeout(timeouts[elementSelector]);
24+
25+
const later = () => {
26+
delete timeouts[elementSelector];
27+
func.apply(context, args);
28+
};
29+
30+
clearTimeout(timeouts[elementSelector]);
31+
timeouts[elementSelector] = setTimeout(later, wait);
32+
};
33+
}
34+
35+
function undebounceHandleEditEvent(param) {
36+
const selector =
37+
elementSelectorCache.get(param.el) || getUniqueSelector(param.el);
38+
39+
const event = {
40+
createdAt: new Date().toISOString(),
41+
selector: selector,
42+
editType: param.editType,
43+
newVal: param.newValue,
44+
oldVal: param.oldValue,
45+
};
46+
addToHistory(event);
47+
}
48+
49+
let debouncedHandleEditEvent = debounce(undebounceHandleEditEvent, 1000);
50+
51+
export function handleEditEvent(param) {
52+
if (param.editType === EditType.STYLE || param.editType === EditType.TEXT) {
53+
debouncedHandleEditEvent(param);
54+
} else {
55+
undebounceHandleEditEvent(param);
56+
}
57+
}

app/features/flex.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import test from 'ava'
22

33
import { setupPptrTab, teardownPptrTab, changeMode, getActiveTool, pptrMetaKey }
4-
from '../../tests/helpers'
4+
from '../../tests/helpers'
55

6-
const tool = 'align'
7-
const test_selector = '[intro] b'
6+
const tool = 'align'
7+
const test_selector = '[intro] b'
88

99

1010
test.beforeEach(async t => {

app/features/guides.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import test from 'ava'
22

3-
import { setupPptrTab, teardownPptrTab }
4-
from '../../tests/helpers'
3+
import { setupPptrTab, teardownPptrTab }
4+
from '../../tests/helpers'
55

66
test.beforeEach(setupPptrTab)
77

app/features/history.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
Types: Not all applicable
3+
4+
export type EditEvent = {
5+
createdAt: string;
6+
selector: string;
7+
editType: EditType;
8+
newVal: Record<string, string> | TextVal;
9+
oldVal: Record<string, string> | TextVal;
10+
}
11+
12+
export type TextVal = {
13+
text: string;
14+
}
15+
16+
export enum EditType {
17+
TEXT = "TEXT",
18+
STYLE = "STYLE",
19+
ATTR = "ATTR",
20+
INSERT = "INSERT",
21+
REMOVE = "REMOVE",
22+
}
23+
*/
24+
25+
export const EditType = {
26+
TEXT: "TEXT",
27+
STYLE: "STYLE",
28+
ATTR: "ATTR",
29+
INSERT: "INSERT",
30+
REMOVE: "REMOVE",
31+
}
32+
export let history = [];
33+
export let redo = [];
34+
35+
export function clearHistory() {
36+
history = [];
37+
redo = [];
38+
}
39+
40+
// Check keys to deduplicate events
41+
function compareKeys(a, b) {
42+
if (!a || !b) return false;
43+
const set1 = new Set(Object.keys(a));
44+
const set2 = new Set(Object.keys(b));
45+
if (set1.size !== set2.size) return false;
46+
for (let item of set1) {
47+
if (!set2.has(item)) return false;
48+
}
49+
return true;
50+
}
51+
52+
53+
export function addToHistory(event) {
54+
if (history.length === 0) {
55+
history.push(event);
56+
return;
57+
}
58+
59+
// Deduplicate last event
60+
const lastEvent = history[history.length - 1];
61+
if (
62+
lastEvent.editType === event.editType &&
63+
lastEvent.selector === event.selector &&
64+
compareKeys(lastEvent.newVal, event.newVal)
65+
) {
66+
lastEvent.newVal = event.newVal;
67+
lastEvent.createdAt = event.createdAt;
68+
history[history.length - 1] = lastEvent;
69+
} else {
70+
history.push(event);
71+
}
72+
}
73+
74+
export function undoLastEvent() {
75+
if (history.length === 0) {
76+
return;
77+
}
78+
const lastEvent = history.pop();
79+
if (lastEvent) {
80+
const reverseEvent = createReverseEvent(lastEvent);
81+
applyEvent(reverseEvent);
82+
redo.push(lastEvent);
83+
}
84+
}
85+
86+
export function redoLastEvent() {
87+
const event = redo.pop();
88+
if (event) {
89+
applyEvent(event);
90+
history.push(event);
91+
}
92+
}
93+
94+
function createReverseEvent(event) {
95+
switch (event.editType) {
96+
// Not handling insert and remove types
97+
case EditType.STYLE || EditType.TEXT:
98+
default:
99+
return {
100+
createdAt: event.createdAt,
101+
selector: event.selector,
102+
editType: event.editType,
103+
newVal: event.oldVal,
104+
oldVal: event.newVal,
105+
};
106+
}
107+
}
108+
109+
function applyStyleEvent(event, element) {
110+
if (!element) return;
111+
Object.entries(event.newVal).forEach(([style, newVal]) => {
112+
element.style[style] = newVal;
113+
});
114+
}
115+
116+
function applyTextEvent(event, element) {
117+
if (!element) return;
118+
const newVal = event.newVal;
119+
element.textContent = newVal.text;
120+
}
121+
122+
123+
function applyEvent(event) {
124+
const element = document.querySelector(event.selector);
125+
switch (event.editType) {
126+
case EditType.STYLE:
127+
applyStyleEvent(event, element);
128+
break;
129+
case EditType.TEXT:
130+
applyTextEvent(event, element);
131+
break;
132+
default:
133+
console.error('Unsupported edit type');
134+
break;
135+
}
136+
}

app/features/history.test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import test from 'ava'
2+
import { EditType, clearHistory, history, redo, addToHistory, undoLastEvent, redoLastEvent } from './history';
3+
4+
import { setupPptrTab, teardownPptrTab }
5+
from '../../tests/helpers'
6+
7+
test.beforeEach(setupPptrTab)
8+
test.afterEach(teardownPptrTab)
9+
10+
// Note: I had to comment out applyEvent to run. Should mock document.
11+
12+
let mockEvent, mockEvent1;
13+
test.beforeEach(() => {
14+
mockEvent = {
15+
createdAt: new Date().toISOString(),
16+
selector: '.test',
17+
editType: EditType.STYLE,
18+
newVal: { 'color': 'red' },
19+
oldVal: { 'color': 'blue' },
20+
};
21+
22+
mockEvent1 = {
23+
createdAt: new Date().toISOString(),
24+
selector: '.test1',
25+
editType: EditType.STYLE,
26+
newVal: { 'color': 'red1' },
27+
oldVal: { 'color': 'blue1' },
28+
};
29+
});
30+
31+
test('addToHistory adds an event to the history', t => {
32+
clearHistory()
33+
addToHistory(mockEvent);
34+
t.is(history.length, 1);
35+
t.deepEqual(history[0], mockEvent);
36+
});
37+
38+
test('addToHistory deduplicates events in history', t => {
39+
clearHistory()
40+
addToHistory(mockEvent);
41+
addToHistory(mockEvent);
42+
addToHistory(mockEvent1);
43+
addToHistory(mockEvent1);
44+
t.is(history.length, 2);
45+
t.deepEqual(history[0], mockEvent);
46+
t.deepEqual(history[1], mockEvent1);
47+
});
48+
49+
test('addToHistory deduplicates events in history if multiple', t => {
50+
clearHistory()
51+
addToHistory(mockEvent);
52+
addToHistory(mockEvent1);
53+
addToHistory(mockEvent1);
54+
addToHistory(mockEvent);
55+
addToHistory(mockEvent);
56+
t.is(history.length, 3);
57+
t.deepEqual(history[0], mockEvent);
58+
t.deepEqual(history[1], mockEvent1);
59+
t.deepEqual(history[2], mockEvent);
60+
});
61+
62+
test('undoLastEvent moves the last event from history to redo', t => {
63+
clearHistory()
64+
// Add to history and then undo
65+
addToHistory(mockEvent);
66+
undoLastEvent();
67+
t.is(history.length, 0);
68+
t.is(redo.length, 1);
69+
});
70+
71+
test('redoLastEvent moves the last event from redo to history', t => {
72+
clearHistory()
73+
// Manually simulate an undo action
74+
addToHistory(mockEvent);
75+
undoLastEvent();
76+
redoLastEvent();
77+
t.is(history.length, 1);
78+
t.is(redo.length, 0);
79+
});

app/utilities/selector.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { finder } from '@medv/finder'
2+
3+
export const getUniqueSelector = (el) => {
4+
let selector = el.tagName.toLowerCase()
5+
try {
6+
if (el.nodeType !== Node.ELEMENT_NODE) { return selector }
7+
// Class names can change too much between states so should ignore.
8+
selector = finder(el, { className: () => false })
9+
} catch (e) {
10+
console.error("Error creating selector ", e);
11+
}
12+
return selector
13+
}

0 commit comments

Comments
 (0)