@aprovan/bobbin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +16 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +402 -0
- package/dist/index.js +3704 -0
- package/package.json +30 -0
- package/src/Bobbin.tsx +89 -0
- package/src/components/EditPanel/EditPanel.tsx +376 -0
- package/src/components/EditPanel/controls/ColorPicker.tsx +138 -0
- package/src/components/EditPanel/controls/QuickSelectDropdown.tsx +142 -0
- package/src/components/EditPanel/controls/SliderInput.tsx +94 -0
- package/src/components/EditPanel/controls/SpacingControl.tsx +285 -0
- package/src/components/EditPanel/controls/ToggleGroup.tsx +37 -0
- package/src/components/EditPanel/controls/TokenDropdown.tsx +33 -0
- package/src/components/EditPanel/sections/AnnotationSection.tsx +136 -0
- package/src/components/EditPanel/sections/BackgroundSection.tsx +79 -0
- package/src/components/EditPanel/sections/EffectsSection.tsx +85 -0
- package/src/components/EditPanel/sections/LayoutSection.tsx +224 -0
- package/src/components/EditPanel/sections/SectionWrapper.tsx +57 -0
- package/src/components/EditPanel/sections/SizeSection.tsx +166 -0
- package/src/components/EditPanel/sections/SpacingSection.tsx +69 -0
- package/src/components/EditPanel/sections/TypographySection.tsx +148 -0
- package/src/components/Inspector/Inspector.tsx +221 -0
- package/src/components/Overlay/ControlHandles.tsx +572 -0
- package/src/components/Overlay/MarginPaddingOverlay.tsx +229 -0
- package/src/components/Overlay/SelectionOverlay.tsx +73 -0
- package/src/components/Pill/Pill.tsx +155 -0
- package/src/components/ThemeToggle/ThemeToggle.tsx +72 -0
- package/src/core/changeSerializer.ts +139 -0
- package/src/core/useBobbin.ts +399 -0
- package/src/core/useChangeTracker.ts +186 -0
- package/src/core/useClipboard.ts +21 -0
- package/src/core/useElementSelection.ts +146 -0
- package/src/index.ts +46 -0
- package/src/tokens/borders.ts +19 -0
- package/src/tokens/colors.ts +150 -0
- package/src/tokens/index.ts +37 -0
- package/src/tokens/shadows.ts +10 -0
- package/src/tokens/spacing.ts +37 -0
- package/src/tokens/typography.ts +51 -0
- package/src/types.ts +157 -0
- package/src/utils/animation.ts +40 -0
- package/src/utils/dom.ts +36 -0
- package/src/utils/selectors.ts +76 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
BobbinState,
|
|
4
|
+
BobbinActions,
|
|
5
|
+
BobbinProps,
|
|
6
|
+
DesignTokens,
|
|
7
|
+
Annotation,
|
|
8
|
+
} from '../types';
|
|
9
|
+
import { useElementSelection } from './useElementSelection';
|
|
10
|
+
import { useChangeTracker } from './useChangeTracker';
|
|
11
|
+
import { useClipboard } from './useClipboard';
|
|
12
|
+
import { serializeChangesToYAML } from './changeSerializer';
|
|
13
|
+
import { defaultTokens } from '../tokens';
|
|
14
|
+
import { getElementPath, getElementXPath } from '../utils/selectors';
|
|
15
|
+
import { applyStyleToElement, enableContentEditable } from '../utils/dom';
|
|
16
|
+
|
|
17
|
+
export function useBobbin(props: BobbinProps = {}) {
|
|
18
|
+
const {
|
|
19
|
+
tokens: customTokens,
|
|
20
|
+
container,
|
|
21
|
+
defaultActive = false,
|
|
22
|
+
onChanges,
|
|
23
|
+
onSelect,
|
|
24
|
+
exclude = [],
|
|
25
|
+
} = props;
|
|
26
|
+
|
|
27
|
+
const [isActive, setIsActive] = useState(defaultActive);
|
|
28
|
+
const [isPillExpanded, setIsPillExpanded] = useState(false);
|
|
29
|
+
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
30
|
+
const [showMarginPadding, setShowMarginPadding] = useState(false);
|
|
31
|
+
const [activePanel, setActivePanel] = useState<'style' | 'inspector' | null>(
|
|
32
|
+
'style',
|
|
33
|
+
);
|
|
34
|
+
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
|
35
|
+
|
|
36
|
+
// Merge tokens
|
|
37
|
+
const tokens = useMemo<DesignTokens>(
|
|
38
|
+
() => ({
|
|
39
|
+
...defaultTokens,
|
|
40
|
+
...customTokens,
|
|
41
|
+
}),
|
|
42
|
+
[customTokens],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Element selection
|
|
46
|
+
const { hoveredElement, selectedElement, selectElement, clearSelection } =
|
|
47
|
+
useElementSelection({
|
|
48
|
+
container,
|
|
49
|
+
exclude: [...exclude, '[data-bobbin]'],
|
|
50
|
+
enabled: isActive,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Change tracking
|
|
54
|
+
const changeTracker = useChangeTracker();
|
|
55
|
+
|
|
56
|
+
// Clipboard
|
|
57
|
+
const clipboard = useClipboard();
|
|
58
|
+
|
|
59
|
+
// Enable contenteditable on selection
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (selectedElement) {
|
|
62
|
+
enableContentEditable(selectedElement.element, true);
|
|
63
|
+
return () => enableContentEditable(selectedElement.element, false);
|
|
64
|
+
}
|
|
65
|
+
}, [selectedElement]);
|
|
66
|
+
|
|
67
|
+
// Notify on changes
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
onChanges?.(changeTracker.changes);
|
|
70
|
+
}, [changeTracker.changes, onChanges]);
|
|
71
|
+
|
|
72
|
+
// Notify on selection
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
onSelect?.(selectedElement);
|
|
75
|
+
}, [selectedElement, onSelect]);
|
|
76
|
+
|
|
77
|
+
// Theme management
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const root = document.documentElement;
|
|
80
|
+
if (theme === 'dark') {
|
|
81
|
+
root.classList.add('dark');
|
|
82
|
+
root.style.colorScheme = 'dark';
|
|
83
|
+
} else if (theme === 'light') {
|
|
84
|
+
root.classList.remove('dark');
|
|
85
|
+
root.style.colorScheme = 'light';
|
|
86
|
+
} else {
|
|
87
|
+
// System preference
|
|
88
|
+
const prefersDark = window.matchMedia(
|
|
89
|
+
'(prefers-color-scheme: dark)',
|
|
90
|
+
).matches;
|
|
91
|
+
root.classList.toggle('dark', prefersDark);
|
|
92
|
+
root.style.colorScheme = prefersDark ? 'dark' : 'light';
|
|
93
|
+
}
|
|
94
|
+
}, [theme]);
|
|
95
|
+
|
|
96
|
+
// Actions
|
|
97
|
+
const activate = useCallback(() => setIsActive(true), []);
|
|
98
|
+
const deactivate = useCallback(() => {
|
|
99
|
+
setIsActive(false);
|
|
100
|
+
clearSelection();
|
|
101
|
+
}, [clearSelection]);
|
|
102
|
+
|
|
103
|
+
const applyStyle = useCallback(
|
|
104
|
+
(property: string, value: string) => {
|
|
105
|
+
if (!selectedElement) return;
|
|
106
|
+
|
|
107
|
+
const el = selectedElement.element;
|
|
108
|
+
const originalValue = getComputedStyle(el).getPropertyValue(property);
|
|
109
|
+
|
|
110
|
+
applyStyleToElement(el, property, value);
|
|
111
|
+
changeTracker.recordStyleChange(
|
|
112
|
+
selectedElement.path,
|
|
113
|
+
selectedElement.xpath,
|
|
114
|
+
selectedElement.tagName,
|
|
115
|
+
property,
|
|
116
|
+
value,
|
|
117
|
+
originalValue,
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
[selectedElement, changeTracker],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const deleteElement = useCallback(() => {
|
|
124
|
+
if (!selectedElement) return;
|
|
125
|
+
|
|
126
|
+
const el = selectedElement.element;
|
|
127
|
+
const parent = el.parentElement;
|
|
128
|
+
if (!parent) return;
|
|
129
|
+
|
|
130
|
+
changeTracker.recordChange(
|
|
131
|
+
'delete',
|
|
132
|
+
selectedElement.path,
|
|
133
|
+
selectedElement.xpath,
|
|
134
|
+
selectedElement.tagName,
|
|
135
|
+
el.outerHTML,
|
|
136
|
+
null,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
el.remove();
|
|
140
|
+
clearSelection();
|
|
141
|
+
}, [selectedElement, changeTracker, clearSelection]);
|
|
142
|
+
|
|
143
|
+
const moveElement = useCallback(
|
|
144
|
+
(targetParent: HTMLElement, index: number) => {
|
|
145
|
+
if (!selectedElement) return;
|
|
146
|
+
|
|
147
|
+
const el = selectedElement.element;
|
|
148
|
+
const fromParent = el.parentElement;
|
|
149
|
+
if (!fromParent) return;
|
|
150
|
+
|
|
151
|
+
const fromIndex = Array.from(fromParent.children).indexOf(el);
|
|
152
|
+
const fromPath = getElementPath(fromParent);
|
|
153
|
+
const toPath = getElementPath(targetParent);
|
|
154
|
+
|
|
155
|
+
if (index >= targetParent.children.length) {
|
|
156
|
+
targetParent.appendChild(el);
|
|
157
|
+
} else {
|
|
158
|
+
const referenceNode = targetParent.children[index] ?? null;
|
|
159
|
+
targetParent.insertBefore(el, referenceNode);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
changeTracker.recordMoveChange(
|
|
163
|
+
selectedElement.path,
|
|
164
|
+
selectedElement.xpath,
|
|
165
|
+
selectedElement.tagName,
|
|
166
|
+
fromPath,
|
|
167
|
+
fromIndex,
|
|
168
|
+
toPath,
|
|
169
|
+
index,
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
[selectedElement, changeTracker],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const duplicateElement = useCallback(() => {
|
|
176
|
+
if (!selectedElement) return;
|
|
177
|
+
|
|
178
|
+
const el = selectedElement.element;
|
|
179
|
+
const clone = el.cloneNode(true) as HTMLElement;
|
|
180
|
+
const parent = el.parentElement;
|
|
181
|
+
|
|
182
|
+
if (parent) {
|
|
183
|
+
parent.insertBefore(clone, el.nextSibling);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
changeTracker.recordChange(
|
|
187
|
+
'duplicate',
|
|
188
|
+
selectedElement.path,
|
|
189
|
+
selectedElement.xpath,
|
|
190
|
+
selectedElement.tagName,
|
|
191
|
+
null,
|
|
192
|
+
clone.outerHTML,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Select the newly duplicated element
|
|
196
|
+
selectElement(clone);
|
|
197
|
+
}, [selectedElement, changeTracker, selectElement]);
|
|
198
|
+
|
|
199
|
+
const insertElement = useCallback(
|
|
200
|
+
(direction: 'before' | 'after' | 'child', content = '') => {
|
|
201
|
+
if (!selectedElement) return;
|
|
202
|
+
|
|
203
|
+
const el = selectedElement.element;
|
|
204
|
+
const newEl = document.createElement('span');
|
|
205
|
+
newEl.textContent = content || '\u200B'; // Zero-width space if empty
|
|
206
|
+
newEl.contentEditable = 'true';
|
|
207
|
+
|
|
208
|
+
if (direction === 'child') {
|
|
209
|
+
el.appendChild(newEl);
|
|
210
|
+
} else if (direction === 'before') {
|
|
211
|
+
el.parentElement?.insertBefore(newEl, el);
|
|
212
|
+
} else {
|
|
213
|
+
el.parentElement?.insertBefore(newEl, el.nextSibling);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
changeTracker.recordChange(
|
|
217
|
+
'insert',
|
|
218
|
+
getElementPath(newEl),
|
|
219
|
+
getElementXPath(newEl),
|
|
220
|
+
'span',
|
|
221
|
+
null,
|
|
222
|
+
newEl.outerHTML,
|
|
223
|
+
{ direction },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Select the newly inserted element
|
|
227
|
+
selectElement(newEl);
|
|
228
|
+
},
|
|
229
|
+
[selectedElement, changeTracker, selectElement],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const copyElement = useCallback(() => {
|
|
233
|
+
if (!selectedElement) return;
|
|
234
|
+
clipboard.copy(selectedElement);
|
|
235
|
+
}, [selectedElement, clipboard]);
|
|
236
|
+
|
|
237
|
+
const pasteElement = useCallback(
|
|
238
|
+
(direction: 'before' | 'after' | 'child') => {
|
|
239
|
+
if (!selectedElement || !clipboard.copied) return;
|
|
240
|
+
|
|
241
|
+
const el = selectedElement.element;
|
|
242
|
+
const clone = clipboard.copied.element.cloneNode(true) as HTMLElement;
|
|
243
|
+
|
|
244
|
+
if (direction === 'child') {
|
|
245
|
+
el.appendChild(clone);
|
|
246
|
+
} else if (direction === 'before') {
|
|
247
|
+
el.parentElement?.insertBefore(clone, el);
|
|
248
|
+
} else {
|
|
249
|
+
el.parentElement?.insertBefore(clone, el.nextSibling);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
changeTracker.recordChange(
|
|
253
|
+
'insert',
|
|
254
|
+
getElementPath(clone),
|
|
255
|
+
getElementXPath(clone),
|
|
256
|
+
clone.tagName.toLowerCase(),
|
|
257
|
+
null,
|
|
258
|
+
clone.outerHTML,
|
|
259
|
+
{ source: 'paste', direction },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Select the newly pasted element
|
|
263
|
+
selectElement(clone);
|
|
264
|
+
},
|
|
265
|
+
[selectedElement, clipboard, changeTracker, selectElement],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const annotate = useCallback(
|
|
269
|
+
(content: string) => {
|
|
270
|
+
if (!selectedElement) return;
|
|
271
|
+
|
|
272
|
+
setAnnotations((prev) => {
|
|
273
|
+
// Check if annotation already exists for this element
|
|
274
|
+
const existingIndex = prev.findIndex(
|
|
275
|
+
(a) => a.elementPath === selectedElement.path,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (existingIndex >= 0) {
|
|
279
|
+
const existing = prev[existingIndex];
|
|
280
|
+
if (!existing) return prev;
|
|
281
|
+
|
|
282
|
+
// Update existing annotation
|
|
283
|
+
if (!content.trim()) {
|
|
284
|
+
// Remove annotation if content is empty
|
|
285
|
+
return prev.filter((_, i) => i !== existingIndex);
|
|
286
|
+
}
|
|
287
|
+
const updated = [...prev];
|
|
288
|
+
updated[existingIndex] = {
|
|
289
|
+
id: existing.id,
|
|
290
|
+
elementPath: existing.elementPath,
|
|
291
|
+
elementXpath: existing.elementXpath,
|
|
292
|
+
content,
|
|
293
|
+
createdAt: Date.now(),
|
|
294
|
+
};
|
|
295
|
+
return updated;
|
|
296
|
+
} else if (content.trim()) {
|
|
297
|
+
// Add new annotation only if content is not empty
|
|
298
|
+
const annotation: Annotation = {
|
|
299
|
+
id: crypto.randomUUID(),
|
|
300
|
+
elementPath: selectedElement.path,
|
|
301
|
+
elementXpath: selectedElement.xpath,
|
|
302
|
+
content,
|
|
303
|
+
createdAt: Date.now(),
|
|
304
|
+
};
|
|
305
|
+
return [...prev, annotation];
|
|
306
|
+
}
|
|
307
|
+
return prev;
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
[selectedElement],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const toggleMarginPadding = useCallback(() => {
|
|
314
|
+
setShowMarginPadding((prev) => !prev);
|
|
315
|
+
}, []);
|
|
316
|
+
|
|
317
|
+
const toggleTheme = useCallback(() => {
|
|
318
|
+
setTheme((prev) => {
|
|
319
|
+
if (prev === 'light') return 'dark';
|
|
320
|
+
if (prev === 'dark') return 'system';
|
|
321
|
+
return 'light';
|
|
322
|
+
});
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const undo = useCallback(() => {
|
|
326
|
+
const lastChange = changeTracker.undo();
|
|
327
|
+
if (!lastChange) return;
|
|
328
|
+
|
|
329
|
+
// TODO: Implement actual undo logic based on change type
|
|
330
|
+
console.log('Undoing:', lastChange);
|
|
331
|
+
}, [changeTracker]);
|
|
332
|
+
|
|
333
|
+
// Reset all style changes by reverting to original values
|
|
334
|
+
const resetChanges = useCallback(() => {
|
|
335
|
+
const originalStates = changeTracker.originalStates;
|
|
336
|
+
|
|
337
|
+
// Revert all changes by applying original values
|
|
338
|
+
for (const [path, properties] of originalStates.entries()) {
|
|
339
|
+
// Find the element by path
|
|
340
|
+
const el = document.querySelector(path) as HTMLElement;
|
|
341
|
+
if (!el) continue;
|
|
342
|
+
|
|
343
|
+
for (const [property, originalValue] of properties.entries()) {
|
|
344
|
+
applyStyleToElement(el, property, originalValue);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
changeTracker.clearChanges();
|
|
349
|
+
}, [changeTracker]);
|
|
350
|
+
|
|
351
|
+
const exportChanges = useCallback(() => {
|
|
352
|
+
return serializeChangesToYAML(
|
|
353
|
+
changeTracker.deduplicatedChanges,
|
|
354
|
+
annotations,
|
|
355
|
+
);
|
|
356
|
+
}, [changeTracker.deduplicatedChanges, annotations]);
|
|
357
|
+
|
|
358
|
+
const state: BobbinState = {
|
|
359
|
+
isActive,
|
|
360
|
+
isPillExpanded,
|
|
361
|
+
hoveredElement,
|
|
362
|
+
selectedElement,
|
|
363
|
+
changes: changeTracker.deduplicatedChanges,
|
|
364
|
+
annotations,
|
|
365
|
+
clipboard: clipboard.copied,
|
|
366
|
+
showMarginPadding,
|
|
367
|
+
activePanel,
|
|
368
|
+
theme,
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const actions: BobbinActions = {
|
|
372
|
+
activate,
|
|
373
|
+
deactivate,
|
|
374
|
+
selectElement,
|
|
375
|
+
clearSelection,
|
|
376
|
+
applyStyle,
|
|
377
|
+
deleteElement,
|
|
378
|
+
moveElement,
|
|
379
|
+
duplicateElement,
|
|
380
|
+
insertElement,
|
|
381
|
+
copyElement,
|
|
382
|
+
pasteElement,
|
|
383
|
+
annotate,
|
|
384
|
+
toggleMarginPadding,
|
|
385
|
+
toggleTheme,
|
|
386
|
+
undo,
|
|
387
|
+
exportChanges,
|
|
388
|
+
getChanges: changeTracker.getChanges,
|
|
389
|
+
resetChanges,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
...state,
|
|
394
|
+
...actions,
|
|
395
|
+
tokens,
|
|
396
|
+
setActivePanel,
|
|
397
|
+
setIsPillExpanded,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
Change,
|
|
4
|
+
StyleChange,
|
|
5
|
+
TextChange,
|
|
6
|
+
MoveChange,
|
|
7
|
+
ChangeType,
|
|
8
|
+
} from '../types';
|
|
9
|
+
import { generateId } from '../utils/selectors';
|
|
10
|
+
|
|
11
|
+
export function useChangeTracker() {
|
|
12
|
+
const [changes, setChanges] = useState<Change[]>([]);
|
|
13
|
+
const historyRef = useRef<Change[]>([]);
|
|
14
|
+
const originalStatesRef = useRef<Map<string, Map<string, string>>>(new Map());
|
|
15
|
+
|
|
16
|
+
const recordOriginalState = useCallback(
|
|
17
|
+
(path: string, property: string, value: string) => {
|
|
18
|
+
if (!originalStatesRef.current.has(path)) {
|
|
19
|
+
originalStatesRef.current.set(path, new Map());
|
|
20
|
+
}
|
|
21
|
+
const elementState = originalStatesRef.current.get(path)!;
|
|
22
|
+
if (!elementState.has(property)) {
|
|
23
|
+
elementState.set(property, value);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
[],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const addChange = useCallback(
|
|
30
|
+
<T extends Change>(change: Omit<T, 'id' | 'timestamp'>) => {
|
|
31
|
+
const fullChange = {
|
|
32
|
+
...change,
|
|
33
|
+
id: generateId(),
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
} as T;
|
|
36
|
+
|
|
37
|
+
setChanges((prev) => [...prev, fullChange]);
|
|
38
|
+
historyRef.current.push(fullChange);
|
|
39
|
+
return fullChange;
|
|
40
|
+
},
|
|
41
|
+
[],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const recordStyleChange = useCallback(
|
|
45
|
+
(
|
|
46
|
+
path: string,
|
|
47
|
+
xpath: string,
|
|
48
|
+
tagName: string,
|
|
49
|
+
property: string,
|
|
50
|
+
value: string,
|
|
51
|
+
originalValue: string,
|
|
52
|
+
): StyleChange => {
|
|
53
|
+
recordOriginalState(path, property, originalValue);
|
|
54
|
+
|
|
55
|
+
return addChange<StyleChange>({
|
|
56
|
+
type: 'style',
|
|
57
|
+
target: { path, xpath, tagName },
|
|
58
|
+
before: { property, value: originalValue },
|
|
59
|
+
after: { property, value },
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
[addChange, recordOriginalState],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const recordTextChange = useCallback(
|
|
66
|
+
(
|
|
67
|
+
path: string,
|
|
68
|
+
xpath: string,
|
|
69
|
+
tagName: string,
|
|
70
|
+
originalText: string,
|
|
71
|
+
newText: string,
|
|
72
|
+
): TextChange => {
|
|
73
|
+
return addChange<TextChange>({
|
|
74
|
+
type: 'text',
|
|
75
|
+
target: { path, xpath, tagName },
|
|
76
|
+
before: originalText,
|
|
77
|
+
after: newText,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
[addChange],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const recordMoveChange = useCallback(
|
|
84
|
+
(
|
|
85
|
+
path: string,
|
|
86
|
+
xpath: string,
|
|
87
|
+
tagName: string,
|
|
88
|
+
fromParent: string,
|
|
89
|
+
fromIndex: number,
|
|
90
|
+
toParent: string,
|
|
91
|
+
toIndex: number,
|
|
92
|
+
): MoveChange => {
|
|
93
|
+
return addChange<MoveChange>({
|
|
94
|
+
type: 'move',
|
|
95
|
+
target: { path, xpath, tagName },
|
|
96
|
+
before: { parent: fromParent, index: fromIndex },
|
|
97
|
+
after: { parent: toParent, index: toIndex },
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
[addChange],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const recordChange = useCallback(
|
|
104
|
+
(
|
|
105
|
+
type: ChangeType,
|
|
106
|
+
path: string,
|
|
107
|
+
xpath: string,
|
|
108
|
+
tagName: string,
|
|
109
|
+
before: unknown,
|
|
110
|
+
after: unknown,
|
|
111
|
+
metadata?: Record<string, unknown>,
|
|
112
|
+
) => {
|
|
113
|
+
return addChange({
|
|
114
|
+
type,
|
|
115
|
+
target: { path, xpath, tagName },
|
|
116
|
+
before,
|
|
117
|
+
after,
|
|
118
|
+
metadata,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
[addChange],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const undo = useCallback(() => {
|
|
125
|
+
if (changes.length === 0) return null;
|
|
126
|
+
|
|
127
|
+
const lastChange = changes[changes.length - 1];
|
|
128
|
+
setChanges((prev) => prev.slice(0, -1));
|
|
129
|
+
return lastChange;
|
|
130
|
+
}, [changes]);
|
|
131
|
+
|
|
132
|
+
const clearChanges = useCallback(() => {
|
|
133
|
+
setChanges([]);
|
|
134
|
+
originalStatesRef.current.clear();
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const getChanges = useCallback(() => [...changes], [changes]);
|
|
138
|
+
|
|
139
|
+
// Deduplicated changes - only count unique (element + property) combinations
|
|
140
|
+
// This prevents counting every keystroke as a separate change
|
|
141
|
+
const deduplicatedChanges = useMemo(() => {
|
|
142
|
+
const uniqueChanges = new Map<string, Change>();
|
|
143
|
+
|
|
144
|
+
for (const change of changes) {
|
|
145
|
+
let key: string;
|
|
146
|
+
|
|
147
|
+
if (change.type === 'style') {
|
|
148
|
+
const styleChange = change as StyleChange;
|
|
149
|
+
key = `${change.target.path}:style:${styleChange.after.property}`;
|
|
150
|
+
} else {
|
|
151
|
+
key = `${change.target.path}:${change.type}:${change.id}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check if the value has changed back to original
|
|
155
|
+
if (change.type === 'style') {
|
|
156
|
+
const styleChange = change as StyleChange;
|
|
157
|
+
const originalValue = originalStatesRef.current
|
|
158
|
+
.get(change.target.path)
|
|
159
|
+
?.get(styleChange.after.property);
|
|
160
|
+
if (originalValue === styleChange.after.value) {
|
|
161
|
+
// Value is back to original, remove from unique changes
|
|
162
|
+
uniqueChanges.delete(key);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
uniqueChanges.set(key, change);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return Array.from(uniqueChanges.values());
|
|
171
|
+
}, [changes]);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
changes,
|
|
175
|
+
deduplicatedChanges,
|
|
176
|
+
changeCount: deduplicatedChanges.length,
|
|
177
|
+
recordStyleChange,
|
|
178
|
+
recordTextChange,
|
|
179
|
+
recordMoveChange,
|
|
180
|
+
recordChange,
|
|
181
|
+
undo,
|
|
182
|
+
clearChanges,
|
|
183
|
+
getChanges,
|
|
184
|
+
originalStates: originalStatesRef.current,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import type { SelectedElement } from '../types';
|
|
3
|
+
|
|
4
|
+
export function useClipboard() {
|
|
5
|
+
const [copied, setCopied] = useState<SelectedElement | null>(null);
|
|
6
|
+
|
|
7
|
+
const copy = useCallback((element: SelectedElement) => {
|
|
8
|
+
setCopied(element);
|
|
9
|
+
}, []);
|
|
10
|
+
|
|
11
|
+
const clear = useCallback(() => {
|
|
12
|
+
setCopied(null);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
copied,
|
|
17
|
+
copy,
|
|
18
|
+
clear,
|
|
19
|
+
hasCopied: copied !== null,
|
|
20
|
+
};
|
|
21
|
+
}
|