@aprovan/bobbin 0.1.0-dev.03aaf5b

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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +16 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +402 -0
  4. package/dist/index.js +3704 -0
  5. package/package.json +30 -0
  6. package/src/Bobbin.tsx +89 -0
  7. package/src/components/EditPanel/EditPanel.tsx +376 -0
  8. package/src/components/EditPanel/controls/ColorPicker.tsx +138 -0
  9. package/src/components/EditPanel/controls/QuickSelectDropdown.tsx +142 -0
  10. package/src/components/EditPanel/controls/SliderInput.tsx +94 -0
  11. package/src/components/EditPanel/controls/SpacingControl.tsx +285 -0
  12. package/src/components/EditPanel/controls/ToggleGroup.tsx +37 -0
  13. package/src/components/EditPanel/controls/TokenDropdown.tsx +33 -0
  14. package/src/components/EditPanel/sections/AnnotationSection.tsx +136 -0
  15. package/src/components/EditPanel/sections/BackgroundSection.tsx +79 -0
  16. package/src/components/EditPanel/sections/EffectsSection.tsx +85 -0
  17. package/src/components/EditPanel/sections/LayoutSection.tsx +224 -0
  18. package/src/components/EditPanel/sections/SectionWrapper.tsx +57 -0
  19. package/src/components/EditPanel/sections/SizeSection.tsx +166 -0
  20. package/src/components/EditPanel/sections/SpacingSection.tsx +69 -0
  21. package/src/components/EditPanel/sections/TypographySection.tsx +148 -0
  22. package/src/components/Inspector/Inspector.tsx +221 -0
  23. package/src/components/Overlay/ControlHandles.tsx +572 -0
  24. package/src/components/Overlay/MarginPaddingOverlay.tsx +229 -0
  25. package/src/components/Overlay/SelectionOverlay.tsx +73 -0
  26. package/src/components/Pill/Pill.tsx +155 -0
  27. package/src/components/ThemeToggle/ThemeToggle.tsx +72 -0
  28. package/src/core/changeSerializer.ts +139 -0
  29. package/src/core/useBobbin.ts +399 -0
  30. package/src/core/useChangeTracker.ts +186 -0
  31. package/src/core/useClipboard.ts +21 -0
  32. package/src/core/useElementSelection.ts +146 -0
  33. package/src/index.ts +46 -0
  34. package/src/tokens/borders.ts +19 -0
  35. package/src/tokens/colors.ts +150 -0
  36. package/src/tokens/index.ts +37 -0
  37. package/src/tokens/shadows.ts +10 -0
  38. package/src/tokens/spacing.ts +37 -0
  39. package/src/tokens/typography.ts +51 -0
  40. package/src/types.ts +157 -0
  41. package/src/utils/animation.ts +40 -0
  42. package/src/utils/dom.ts +36 -0
  43. package/src/utils/selectors.ts +76 -0
  44. package/tsconfig.json +10 -0
  45. 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
+ }