@growgroup/visual-editor 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.
Files changed (219) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +52 -0
  3. package/README.md +143 -0
  4. package/dist/editor.css +2 -0
  5. package/package.json +94 -0
  6. package/src/components/DeckSlideRender.tsx +21 -0
  7. package/src/components/SaveNote.tsx +21 -0
  8. package/src/components/auth/AuthProvider.tsx +44 -0
  9. package/src/components/ui/accordion.tsx +57 -0
  10. package/src/components/ui/alert-dialog.tsx +141 -0
  11. package/src/components/ui/badge.tsx +36 -0
  12. package/src/components/ui/button.tsx +56 -0
  13. package/src/components/ui/card.tsx +76 -0
  14. package/src/components/ui/checkbox.tsx +30 -0
  15. package/src/components/ui/collapsible.tsx +11 -0
  16. package/src/components/ui/context-menu.tsx +200 -0
  17. package/src/components/ui/dialog.tsx +122 -0
  18. package/src/components/ui/dropdown-menu.tsx +201 -0
  19. package/src/components/ui/input.tsx +22 -0
  20. package/src/components/ui/label.tsx +26 -0
  21. package/src/components/ui/popover.tsx +33 -0
  22. package/src/components/ui/scroll-area.tsx +48 -0
  23. package/src/components/ui/scrubbable-label.tsx +95 -0
  24. package/src/components/ui/select.tsx +159 -0
  25. package/src/components/ui/separator.tsx +31 -0
  26. package/src/components/ui/slider.tsx +28 -0
  27. package/src/components/ui/switch.tsx +29 -0
  28. package/src/components/ui/tabs.tsx +55 -0
  29. package/src/components/ui/textarea.tsx +22 -0
  30. package/src/components/ui/tooltip.tsx +32 -0
  31. package/src/components/viewer/useDeck.ts +58 -0
  32. package/src/editor/EditorContext.tsx +632 -0
  33. package/src/editor/EditorToolbar.tsx +446 -0
  34. package/src/editor/FrontendVisualEditor.tsx +2595 -0
  35. package/src/editor/autosave.ts +34 -0
  36. package/src/editor/components/AiPromptPopover.tsx +378 -0
  37. package/src/editor/components/BreakpointGuides.tsx +323 -0
  38. package/src/editor/components/BreakpointSelector.tsx +180 -0
  39. package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
  40. package/src/editor/components/CssEditorDialog.tsx +168 -0
  41. package/src/editor/components/EditorCanvas.tsx +987 -0
  42. package/src/editor/components/EditorContextMenu.tsx +515 -0
  43. package/src/editor/components/EditorFooter.tsx +81 -0
  44. package/src/editor/components/EditorHeader.tsx +524 -0
  45. package/src/editor/components/EditorLayerPanel.tsx +1197 -0
  46. package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
  47. package/src/editor/components/EditorSidebar.tsx +226 -0
  48. package/src/editor/components/FigmaColorPicker.tsx +1577 -0
  49. package/src/editor/components/HtmlEditorDialog.tsx +71 -0
  50. package/src/editor/components/HtmlImportDialog.tsx +346 -0
  51. package/src/editor/components/JsEditorDialog.tsx +175 -0
  52. package/src/editor/components/LayerTreeItem.tsx +494 -0
  53. package/src/editor/components/MasterComponentEditor.tsx +846 -0
  54. package/src/editor/components/MediaLibraryDialog.tsx +447 -0
  55. package/src/editor/components/PageSettingsDialog.tsx +776 -0
  56. package/src/editor/components/VariablePicker.tsx +355 -0
  57. package/src/editor/components/VariablesDialog.tsx +564 -0
  58. package/src/editor/components/VariantEditor.tsx +687 -0
  59. package/src/editor/components/VariantMatrix.tsx +507 -0
  60. package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
  61. package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
  62. package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
  63. package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
  64. package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
  65. package/src/editor/components/component-panel/VariantList.tsx +260 -0
  66. package/src/editor/components/component-panel/index.ts +12 -0
  67. package/src/editor/components/index.ts +48 -0
  68. package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
  69. package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
  70. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
  71. package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
  72. package/src/editor/components/multi-page/PageLabel.tsx +34 -0
  73. package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
  74. package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
  75. package/src/editor/components/multi-page/index.ts +7 -0
  76. package/src/editor/components/ppt/PptChrome.tsx +2508 -0
  77. package/src/editor/components/ppt/PptComments.tsx +589 -0
  78. package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
  79. package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
  80. package/src/editor/components/ppt/PptNotes.tsx +246 -0
  81. package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
  82. package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
  83. package/src/editor/components/property-panel/BorderSection.tsx +104 -0
  84. package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
  85. package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
  86. package/src/editor/components/property-panel/EffectSection.tsx +157 -0
  87. package/src/editor/components/property-panel/FillSection.tsx +69 -0
  88. package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
  89. package/src/editor/components/property-panel/ImageSection.tsx +206 -0
  90. package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
  91. package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
  92. package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
  93. package/src/editor/components/property-panel/LinkSection.tsx +103 -0
  94. package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
  95. package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
  96. package/src/editor/components/property-panel/TypographySection.tsx +230 -0
  97. package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
  98. package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
  99. package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
  100. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
  101. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
  102. package/src/editor/components/property-panel/index.ts +24 -0
  103. package/src/editor/components/property-panel/unit-utils.ts +261 -0
  104. package/src/editor/components/shell/EditorTopBar.tsx +392 -0
  105. package/src/editor/components/shell/LeftPanel.tsx +47 -0
  106. package/src/editor/components/shell/PagesPanel.tsx +118 -0
  107. package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
  108. package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
  109. package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
  110. package/src/editor/components/variables-panel/index.ts +7 -0
  111. package/src/editor/constants.ts +468 -0
  112. package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
  113. package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
  114. package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
  115. package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
  116. package/src/editor/contexts/EditorRefsContext.tsx +204 -0
  117. package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
  118. package/src/editor/contexts/EditorToolContext.tsx +58 -0
  119. package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
  120. package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
  121. package/src/editor/contexts/EditorViewContext.tsx +101 -0
  122. package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
  123. package/src/editor/contexts/index.ts +17 -0
  124. package/src/editor/editor-skin.css +122 -0
  125. package/src/editor/hooks/index.ts +38 -0
  126. package/src/editor/hooks/useAiReplace.ts +397 -0
  127. package/src/editor/hooks/useAltMeasure.ts +67 -0
  128. package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
  129. package/src/editor/hooks/useCanvasControls.ts +1222 -0
  130. package/src/editor/hooks/useComponentEditMode.ts +658 -0
  131. package/src/editor/hooks/useComponentInstances.ts +515 -0
  132. package/src/editor/hooks/useContextMenuHandler.ts +157 -0
  133. package/src/editor/hooks/useCoordinateTransform.ts +207 -0
  134. package/src/editor/hooks/useDragResize.ts +1317 -0
  135. package/src/editor/hooks/useDrawingMode.ts +472 -0
  136. package/src/editor/hooks/useEditorColors.ts +144 -0
  137. package/src/editor/hooks/useEditorMessages.ts +108 -0
  138. package/src/editor/hooks/useElementActions.ts +1754 -0
  139. package/src/editor/hooks/useElementSelection.ts +1573 -0
  140. package/src/editor/hooks/useFocusManagement.ts +249 -0
  141. package/src/editor/hooks/useGoogleFonts.ts +305 -0
  142. package/src/editor/hooks/useIframeInitializer.ts +584 -0
  143. package/src/editor/hooks/useIframeSetup.ts +360 -0
  144. package/src/editor/hooks/useImageUpload.ts +270 -0
  145. package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
  146. package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
  147. package/src/editor/hooks/useMarqueeSelection.ts +388 -0
  148. package/src/editor/hooks/useMediaLibrary.ts +129 -0
  149. package/src/editor/hooks/usePageSettingsManager.ts +739 -0
  150. package/src/editor/hooks/useResizablePanel.ts +163 -0
  151. package/src/editor/hooks/useRichPaste.ts +255 -0
  152. package/src/editor/hooks/useTouchGestures.ts +197 -0
  153. package/src/editor/index.ts +27 -0
  154. package/src/editor/types/page-settings.ts +513 -0
  155. package/src/editor/types.ts +382 -0
  156. package/src/editor/utils/align-elements.ts +169 -0
  157. package/src/editor/utils/api-json.ts +21 -0
  158. package/src/editor/utils/aspect-lock.ts +50 -0
  159. package/src/editor/utils/component-renderer.ts +702 -0
  160. package/src/editor/utils/component-sync.ts +845 -0
  161. package/src/editor/utils/crop-mode.ts +265 -0
  162. package/src/editor/utils/dom-utils.ts +2238 -0
  163. package/src/editor/utils/element-effects.ts +218 -0
  164. package/src/editor/utils/eyedropper.ts +35 -0
  165. package/src/editor/utils/figma-export.ts +560 -0
  166. package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
  167. package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
  168. package/src/editor/utils/figma-paste.ts +2262 -0
  169. package/src/editor/utils/flex-reorder.ts +231 -0
  170. package/src/editor/utils/flex-utils.ts +1230 -0
  171. package/src/editor/utils/geometry.ts +232 -0
  172. package/src/editor/utils/grid-layout.ts +210 -0
  173. package/src/editor/utils/html-utils.ts +470 -0
  174. package/src/editor/utils/index.ts +11 -0
  175. package/src/editor/utils/ink-style.ts +57 -0
  176. package/src/editor/utils/inline-format.ts +236 -0
  177. package/src/editor/utils/measure-distance.ts +120 -0
  178. package/src/editor/utils/override-apply.ts +526 -0
  179. package/src/editor/utils/override-detection.ts +613 -0
  180. package/src/editor/utils/paste-processors.ts +609 -0
  181. package/src/editor/utils/paste-sanitizer.ts +244 -0
  182. package/src/editor/utils/restore-flow.ts +188 -0
  183. package/src/editor/utils/shape-library.ts +171 -0
  184. package/src/editor/utils/slide-root.ts +29 -0
  185. package/src/editor/utils/smart-guides.ts +218 -0
  186. package/src/editor/utils/style-utils.ts +685 -0
  187. package/src/editor/utils/table-edit.ts +151 -0
  188. package/src/editor/utils/tailwind-mappings.ts +778 -0
  189. package/src/editor/utils/tailwind-utils.ts +804 -0
  190. package/src/editor/utils/text-highlight.ts +216 -0
  191. package/src/editor/utils/theme-to-variables.ts +341 -0
  192. package/src/editor/utils/variant-resolver.ts +456 -0
  193. package/src/editor/utils/viewport-utils.ts +295 -0
  194. package/src/hooks/useEditorHistory.ts +195 -0
  195. package/src/hooks/useEditorShortcuts.ts +260 -0
  196. package/src/index.ts +40 -0
  197. package/src/io.ts +134 -0
  198. package/src/lib/agent/slide-agent/types.ts +547 -0
  199. package/src/lib/agent/website-agent/types.ts +805 -0
  200. package/src/lib/api/auth-fetch.ts +58 -0
  201. package/src/lib/deck.ts +69 -0
  202. package/src/lib/export.ts +47 -0
  203. package/src/lib/firebase/config.ts +29 -0
  204. package/src/lib/firebase/css-variables.ts +378 -0
  205. package/src/lib/firebase/editor-components.ts +677 -0
  206. package/src/lib/firebase/storage.ts +469 -0
  207. package/src/lib/utils.ts +6 -0
  208. package/src/styles/editor.css +52 -0
  209. package/src/styles/skin.css +122 -0
  210. package/src/types/css-variables.ts +645 -0
  211. package/src/types/editor-components.ts +944 -0
  212. package/src/types/editor.ts +420 -0
  213. package/src/types/page-master.ts +197 -0
  214. package/src/types/page.ts +419 -0
  215. package/src/types/slide.ts +402 -0
  216. package/src/types/website-theme.ts +788 -0
  217. package/src/types/website.ts +374 -0
  218. package/src/vendor/firebase-firestore.ts +13 -0
  219. package/src/vendor/firebase-functions.ts +17 -0
@@ -0,0 +1,1320 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * EditorComponentsContext
5
+ *
6
+ * Manages the component system for the frontend editor.
7
+ * Handles master components, instances, overrides, and variants.
8
+ *
9
+ * Features:
10
+ * - Master component CRUD operations
11
+ * - Instance management with DOM element ID mapping
12
+ * - Override system for instance customization
13
+ * - Variant switching
14
+ * - Instance resolution (master + overrides -> final element)
15
+ */
16
+
17
+ import React, {
18
+ createContext,
19
+ useContext,
20
+ useState,
21
+ useCallback,
22
+ useMemo,
23
+ useEffect,
24
+ useRef,
25
+ } from 'react';
26
+ import type {
27
+ MasterComponent,
28
+ ComponentInstance,
29
+ ComponentLibraryCategory,
30
+ ComponentOverride,
31
+ ComponentElement,
32
+ ComponentVariant,
33
+ EditorComponentsContextValue,
34
+ OverridableProperties,
35
+ } from '../../types/editor-components';
36
+ import {
37
+ saveMasterComponent as saveToFirestore,
38
+ getAllMasterComponents,
39
+ deleteMasterComponent as deleteFromFirestore,
40
+ updateMasterComponent as updateMasterComponentInFirestore,
41
+ saveComponentInstance,
42
+ updateComponentInstance,
43
+ deleteComponentInstance,
44
+ getPageComponentInstances,
45
+ } from '../../lib/firebase/editor-components';
46
+
47
+ // ============================================================
48
+ // Context Creation
49
+ // ============================================================
50
+
51
+ const EditorComponentsContext =
52
+ createContext<EditorComponentsContextValue | null>(null);
53
+
54
+ /**
55
+ * Hook to access the EditorComponentsContext.
56
+ * Must be used within an EditorComponentsProvider.
57
+ */
58
+ export function useEditorComponents(): EditorComponentsContextValue {
59
+ const context = useContext(EditorComponentsContext);
60
+ if (!context) {
61
+ throw new Error(
62
+ 'useEditorComponents must be used within EditorComponentsProvider'
63
+ );
64
+ }
65
+ return context;
66
+ }
67
+
68
+ // ============================================================
69
+ // Helper Functions
70
+ // ============================================================
71
+
72
+ /**
73
+ * Generate a unique ID for components and instances.
74
+ */
75
+ function generateId(): string {
76
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
77
+ }
78
+
79
+ /**
80
+ * Default overridable properties for component elements.
81
+ */
82
+ const defaultOverridable: OverridableProperties = {
83
+ text: true,
84
+ fill: true,
85
+ stroke: true,
86
+ visibility: true,
87
+ image: true,
88
+ };
89
+
90
+ /**
91
+ * Position-related CSS properties that should be excluded from component root elements.
92
+ * These are applied by instance placement instead.
93
+ */
94
+ const POSITION_STYLES = new Set([
95
+ 'position', 'left', 'top', 'right', 'bottom',
96
+ 'transform', 'zIndex', 'z-index'
97
+ ]);
98
+
99
+ /**
100
+ * Convert an HTMLElement to a ComponentElement structure.
101
+ * Exported for use in component editing mode.
102
+ *
103
+ * Handles three cases for content:
104
+ * 1. Element has only text content (no child elements) -> uses textContent
105
+ * 2. Element has only child elements (no text nodes) -> uses children array
106
+ * 3. Element has mixed content (text + elements) -> uses innerHTML
107
+ *
108
+ * @param element - The HTML element to convert
109
+ * @param isRoot - Whether this is the root element (default: true for first call)
110
+ */
111
+ export function htmlElementToComponentElement(element: HTMLElement, isRoot: boolean = true): ComponentElement {
112
+ // Preserve existing component element ID if present (for edit mode), otherwise generate new
113
+ const existingId = element.getAttribute('data-element-id');
114
+ const id = existingId || generateId();
115
+
116
+ // Extract class name using getAttribute to handle SVG elements correctly
117
+ // (element.className returns SVGAnimatedString for SVG elements)
118
+ const className = element.getAttribute('class') || '';
119
+
120
+ // Extract attributes (excluding class and style which are handled separately)
121
+ const attributes: Record<string, string> = {};
122
+ for (const attr of Array.from(element.attributes)) {
123
+ if (attr.name !== 'style' && attr.name !== 'class' && attr.name !== 'id' && !attr.name.startsWith('data-element') && !attr.name.startsWith('data-editable')) {
124
+ attributes[attr.name] = attr.value;
125
+ }
126
+ }
127
+
128
+ // Extract computed styles (only explicitly set ones)
129
+ // For root element: exclude position-related styles (applied by instance placement)
130
+ const styles: Record<string, string> = {};
131
+ const inlineStyle = element.getAttribute('style');
132
+ if (inlineStyle) {
133
+ const stylePairs = inlineStyle.split(';').filter((s) => s.trim());
134
+ for (const pair of stylePairs) {
135
+ // Handle styles with colons in values (like url(data:...) or https://...)
136
+ const colonIndex = pair.indexOf(':');
137
+ if (colonIndex === -1) continue;
138
+ const key = pair.slice(0, colonIndex).trim();
139
+ const value = pair.slice(colonIndex + 1).trim();
140
+ if (key && value) {
141
+ // Skip position-related styles for root element
142
+ if (isRoot && POSITION_STYLES.has(key)) {
143
+ console.log(`[htmlElementToComponentElement] Skipping position style for root: ${key}`);
144
+ continue;
145
+ }
146
+ // Convert kebab-case to camelCase
147
+ const camelKey = key.replace(/-([a-z])/g, (_, letter) =>
148
+ letter.toUpperCase()
149
+ );
150
+ styles[camelKey] = value;
151
+ }
152
+ }
153
+ }
154
+
155
+ // Check for mixed content (text nodes + element nodes)
156
+ const hasElementChildren = element.children.length > 0;
157
+ const childNodes = Array.from(element.childNodes);
158
+ const hasTextNodes = childNodes.some(
159
+ node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
160
+ );
161
+ const hasMixedContent = hasElementChildren && hasTextNodes;
162
+
163
+ let textContent: string | undefined;
164
+ let innerHTML: string | undefined;
165
+ let children: ComponentElement[] = [];
166
+
167
+ if (hasMixedContent) {
168
+ // Mixed content: use innerHTML to preserve all content
169
+ innerHTML = element.innerHTML;
170
+ console.log('[htmlElementToComponentElement] Mixed content detected, using innerHTML');
171
+ } else if (hasElementChildren) {
172
+ // Only element children: recursively process (not root)
173
+ for (const child of Array.from(element.children)) {
174
+ children.push(htmlElementToComponentElement(child as HTMLElement, false));
175
+ }
176
+ } else {
177
+ // Only text content (or empty)
178
+ textContent = element.textContent?.trim() || undefined;
179
+ }
180
+
181
+ const result: ComponentElement = {
182
+ id,
183
+ tagName: element.tagName.toLowerCase(),
184
+ attributes,
185
+ className,
186
+ styles,
187
+ textContent,
188
+ innerHTML,
189
+ children,
190
+ overridable: { ...defaultOverridable },
191
+ };
192
+
193
+ // Debug logging
194
+ console.log('[htmlElementToComponentElement] Converted:', {
195
+ tagName: result.tagName,
196
+ className: result.className,
197
+ hasTextContent: !!result.textContent,
198
+ hasInnerHTML: !!result.innerHTML,
199
+ textContentPreview: result.textContent?.substring(0, 50),
200
+ innerHTMLPreview: result.innerHTML?.substring(0, 50),
201
+ childrenCount: result.children.length,
202
+ stylesCount: Object.keys(result.styles).length,
203
+ });
204
+
205
+ return result;
206
+ }
207
+
208
+ /**
209
+ * Deep clone a ComponentElement.
210
+ */
211
+ function deepCloneElement(element: ComponentElement): ComponentElement {
212
+ return {
213
+ ...element,
214
+ attributes: { ...element.attributes },
215
+ styles: { ...element.styles },
216
+ overridable: { ...element.overridable },
217
+ children: element.children.map(deepCloneElement),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Get an element at a specific path within a ComponentElement tree.
223
+ * Path format: "children.0.children.1" or "root" for the root element.
224
+ */
225
+ function getElementAtPath(
226
+ root: ComponentElement,
227
+ path: string
228
+ ): ComponentElement | null {
229
+ if (path === 'root' || path === '') {
230
+ return root;
231
+ }
232
+
233
+ const parts = path.split('.');
234
+ let current: ComponentElement | undefined = root;
235
+
236
+ for (let i = 0; i < parts.length; i += 2) {
237
+ if (parts[i] !== 'children') {
238
+ return null;
239
+ }
240
+ const index = parseInt(parts[i + 1], 10);
241
+ if (isNaN(index) || !current?.children[index]) {
242
+ return null;
243
+ }
244
+ current = current.children[index];
245
+ }
246
+
247
+ return current || null;
248
+ }
249
+
250
+ /**
251
+ * Find element by ID in a ComponentElement tree.
252
+ */
253
+ function findElementById(
254
+ root: ComponentElement,
255
+ targetId: string
256
+ ): ComponentElement | null {
257
+ if (root.id === targetId) {
258
+ return root;
259
+ }
260
+ for (const child of root.children) {
261
+ const found = findElementById(child, targetId);
262
+ if (found) return found;
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Apply a single override to a ComponentElement tree.
269
+ * Supports both path-based (elementPath) and ID-based (targetElementId) targeting.
270
+ */
271
+ function applyOverrideToElement(
272
+ root: ComponentElement,
273
+ override: ComponentOverride
274
+ ): void {
275
+ // Find target element - support both elementPath and targetElementId
276
+ let element: ComponentElement | null = null;
277
+
278
+ if (override.elementPath) {
279
+ element = getElementAtPath(root, override.elementPath);
280
+ } else if (override.targetElementId) {
281
+ element = findElementById(root, override.targetElementId);
282
+ }
283
+
284
+ if (!element) {
285
+ console.warn(
286
+ `[EditorComponentsContext] Override target not found: ${override.elementPath || override.targetElementId}`
287
+ );
288
+ return;
289
+ }
290
+
291
+ switch (override.type) {
292
+ case 'text':
293
+ element.textContent = override.value as string;
294
+ break;
295
+
296
+ case 'style':
297
+ const styleOverrides = override.value as Record<string, string>;
298
+ element.styles = { ...element.styles, ...styleOverrides };
299
+ break;
300
+
301
+ case 'attribute':
302
+ const attrOverrides = override.value as Record<string, string>;
303
+ element.attributes = { ...element.attributes, ...attrOverrides };
304
+ break;
305
+
306
+ case 'fill':
307
+ // Fill is a style override for background-color
308
+ element.styles = { ...element.styles, backgroundColor: override.value as string };
309
+ break;
310
+
311
+ case 'stroke':
312
+ // Stroke is a style override for border-color
313
+ element.styles = { ...element.styles, borderColor: override.value as string };
314
+ break;
315
+
316
+ case 'visibility':
317
+ if (override.value === false) {
318
+ element.styles = { ...element.styles, display: 'none' };
319
+ } else {
320
+ const { display, ...restStyles } = element.styles;
321
+ element.styles = restStyles;
322
+ }
323
+ break;
324
+
325
+ case 'children':
326
+ element.children = override.value as ComponentElement[];
327
+ break;
328
+
329
+ case 'image':
330
+ // Image override for img elements
331
+ if (typeof override.value === 'object' && 'url' in override.value) {
332
+ element.attributes = { ...element.attributes, src: (override.value as { url: string }).url };
333
+ } else if (typeof override.value === 'string') {
334
+ element.attributes = { ...element.attributes, src: override.value };
335
+ }
336
+ break;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Convert a ComponentElement back to an HTMLElement.
342
+ */
343
+ function componentElementToHtml(
344
+ element: ComponentElement,
345
+ doc: Document = document
346
+ ): HTMLElement {
347
+ const el = doc.createElement(element.tagName);
348
+
349
+ // Set ID
350
+ el.id = element.id;
351
+
352
+ // Set className
353
+ if (element.className) {
354
+ el.className = element.className;
355
+ }
356
+
357
+ // Set attributes
358
+ for (const [key, value] of Object.entries(element.attributes)) {
359
+ if (key !== 'id' && key !== 'class') {
360
+ el.setAttribute(key, value);
361
+ }
362
+ }
363
+
364
+ // Set styles
365
+ const styleString = Object.entries(element.styles)
366
+ .map(([key, value]) => {
367
+ // Convert camelCase to kebab-case
368
+ const kebabKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
369
+ return `${kebabKey}: ${value}`;
370
+ })
371
+ .join('; ');
372
+ if (styleString) {
373
+ el.setAttribute('style', styleString);
374
+ }
375
+
376
+ // Set text content or children
377
+ if (element.textContent !== undefined && element.children.length === 0) {
378
+ el.textContent = element.textContent;
379
+ } else {
380
+ for (const child of element.children) {
381
+ el.appendChild(componentElementToHtml(child, doc));
382
+ }
383
+ }
384
+
385
+ return el;
386
+ }
387
+
388
+ // ============================================================
389
+ // Provider Props
390
+ // ============================================================
391
+
392
+ interface EditorComponentsProviderProps {
393
+ children: React.ReactNode;
394
+ websiteId?: string;
395
+ pageId?: string;
396
+ }
397
+
398
+ // ============================================================
399
+ // Provider Component
400
+ // ============================================================
401
+
402
+ export function EditorComponentsProvider({
403
+ children,
404
+ websiteId,
405
+ pageId,
406
+ }: EditorComponentsProviderProps) {
407
+ // State
408
+ const [masterComponents, setMasterComponents] = useState<
409
+ Map<string, MasterComponent>
410
+ >(new Map());
411
+ const [componentInstances, setComponentInstances] = useState<
412
+ Map<string, ComponentInstance>
413
+ >(new Map());
414
+ const [componentLibrary, setComponentLibrary] = useState<
415
+ ComponentLibraryCategory[]
416
+ >([]);
417
+ const [isLoadingComponents, setIsLoadingComponents] = useState(false);
418
+ const [selectedMasterComponentId, setSelectedMasterComponentId] = useState<
419
+ string | null
420
+ >(null);
421
+ const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(
422
+ null
423
+ );
424
+ const [error, setError] = useState<string | null>(null);
425
+ const [pendingNavigationTarget, setPendingNavigationTarget] = useState<string | null>(null);
426
+
427
+ // ============================================================
428
+ // Navigation
429
+ // ============================================================
430
+
431
+ /**
432
+ * Request to navigate to a master component.
433
+ * This sets a pending navigation target that FrontendVisualEditor will observe
434
+ * to open the component panel and select the specified master.
435
+ */
436
+ const navigateToMasterComponent = useCallback((masterComponentId: string) => {
437
+ setPendingNavigationTarget(masterComponentId);
438
+ }, []);
439
+
440
+ /**
441
+ * Clear the pending navigation request.
442
+ * Called by FrontendVisualEditor after handling the navigation.
443
+ */
444
+ const clearNavigationRequest = useCallback(() => {
445
+ setPendingNavigationTarget(null);
446
+ }, []);
447
+
448
+ // ============================================================
449
+ // Loading
450
+ // ============================================================
451
+
452
+ // Default categories
453
+ // [移植時の修正] 汎用Web向け(Layout/Navigation/Form…)から、提案書スライドの
454
+ // デザイン部品(src/lib/design-parts.ts が登録する chrome2 の構成要素)に合わせたカテゴリへ変更。
455
+ const defaultCategories: ComponentLibraryCategory[] = useMemo(() => [
456
+ {
457
+ id: 'structure',
458
+ name: 'ページ構成',
459
+ description: 'ヘッダー・考察帯など、ページの骨格をつくる要素',
460
+ icon: 'layout',
461
+ color: '#0b6b3a',
462
+ order: 1,
463
+ componentIds: [],
464
+ },
465
+ {
466
+ id: 'block',
467
+ name: 'ブロック',
468
+ description: 'パネル・キーポイントなど、情報をまとめる要素',
469
+ icon: 'file-text',
470
+ color: '#3b82f6',
471
+ order: 2,
472
+ componentIds: [],
473
+ },
474
+ {
475
+ id: 'data',
476
+ name: 'データ表現',
477
+ description: '数値・定義リストなど、根拠を見せる要素',
478
+ icon: 'bar-chart',
479
+ color: '#f59e0b',
480
+ order: 3,
481
+ componentIds: [],
482
+ },
483
+ {
484
+ id: 'label',
485
+ name: 'ラベル',
486
+ description: 'チップなどの区分ラベル',
487
+ icon: 'tag',
488
+ color: '#8b5cf6',
489
+ order: 4,
490
+ componentIds: [],
491
+ },
492
+ {
493
+ id: 'media',
494
+ name: 'メディア',
495
+ description: '画像・図版',
496
+ icon: 'image',
497
+ color: '#ef4444',
498
+ order: 5,
499
+ componentIds: [],
500
+ },
501
+ ], []);
502
+
503
+ const loadComponents = useCallback(async (targetWebsiteId: string) => {
504
+ setIsLoadingComponents(true);
505
+ setError(null);
506
+ try {
507
+ console.log(
508
+ `[EditorComponentsContext] Loading components for website: ${targetWebsiteId}`
509
+ );
510
+
511
+ // Load master components from Firestore
512
+ const components = await getAllMasterComponents(targetWebsiteId);
513
+ console.log(`[EditorComponentsContext] Loaded ${components.length} components`);
514
+
515
+ // Create Map from components
516
+ const componentsMap = new Map<string, MasterComponent>();
517
+ for (const component of components) {
518
+ componentsMap.set(component.id, component);
519
+ }
520
+ setMasterComponents(componentsMap);
521
+
522
+ // Build component library with loaded components
523
+ const categoryMap = new Map<string, string[]>();
524
+ for (const component of components) {
525
+ const categoryId = component.categoryId;
526
+ if (!categoryMap.has(categoryId)) {
527
+ categoryMap.set(categoryId, []);
528
+ }
529
+ categoryMap.get(categoryId)!.push(component.id);
530
+ }
531
+
532
+ // Update categories with loaded component IDs
533
+ const updatedCategories = defaultCategories.map((cat) => ({
534
+ ...cat,
535
+ componentIds: categoryMap.get(cat.id) || [],
536
+ }));
537
+ setComponentLibrary(updatedCategories);
538
+
539
+ setComponentInstances(new Map());
540
+ } catch (err) {
541
+ console.error('[EditorComponentsContext] Failed to load components:', err);
542
+ setError(err instanceof Error ? err.message : 'Failed to load components');
543
+ // Still set default categories even on error
544
+ setComponentLibrary(defaultCategories);
545
+ } finally {
546
+ setIsLoadingComponents(false);
547
+ }
548
+ }, [defaultCategories]);
549
+
550
+ // Initialize on mount or when websiteId changes
551
+ const loadedWebsiteIdRef = useRef<string | null>(null);
552
+ useEffect(() => {
553
+ // Only load if websiteId exists and is different from the last loaded one
554
+ if (websiteId && websiteId !== loadedWebsiteIdRef.current) {
555
+ loadedWebsiteIdRef.current = websiteId;
556
+ loadComponents(websiteId);
557
+ } else if (!websiteId) {
558
+ // Reset to default categories if no websiteId
559
+ setComponentLibrary(defaultCategories);
560
+ setMasterComponents(new Map());
561
+ loadedWebsiteIdRef.current = null;
562
+ }
563
+ }, [websiteId, loadComponents, defaultCategories]);
564
+
565
+ // Load instances when pageId changes
566
+ const loadedPageIdRef = useRef<string | null>(null);
567
+ useEffect(() => {
568
+ if (!websiteId || !pageId) {
569
+ setComponentInstances(new Map());
570
+ loadedPageIdRef.current = null;
571
+ return;
572
+ }
573
+
574
+ // Only load if pageId is different from last loaded
575
+ if (pageId === loadedPageIdRef.current) {
576
+ return;
577
+ }
578
+
579
+ loadedPageIdRef.current = pageId;
580
+
581
+ const loadInstances = async () => {
582
+ try {
583
+ console.log(`[EditorComponentsContext] Loading instances for page: ${pageId}`);
584
+ const instances = await getPageComponentInstances(websiteId, pageId);
585
+ console.log(`[EditorComponentsContext] Loaded ${instances.length} instances`);
586
+
587
+ // Create Map from instances (keyed by domElementId)
588
+ const instancesMap = new Map<string, ComponentInstance>();
589
+ for (const instance of instances) {
590
+ instancesMap.set(instance.domElementId, instance);
591
+ }
592
+ setComponentInstances(instancesMap);
593
+ } catch (err) {
594
+ console.error('[EditorComponentsContext] Failed to load instances:', err);
595
+ setError(err instanceof Error ? err.message : 'Failed to load instances');
596
+ }
597
+ };
598
+
599
+ loadInstances();
600
+ }, [websiteId, pageId]);
601
+
602
+ // ============================================================
603
+ // Master Component Operations
604
+ // ============================================================
605
+
606
+ const createMasterComponent = useCallback(
607
+ (element: HTMLElement, name: string, categoryId: string): MasterComponent => {
608
+ const id = generateId();
609
+ const now = new Date().toISOString();
610
+
611
+ // Convert HTML element to component element
612
+ const rootElement = htmlElementToComponentElement(element);
613
+
614
+ // Create default variant
615
+ const defaultVariant: ComponentVariant = {
616
+ id: generateId(),
617
+ name: 'Default',
618
+ description: 'Default variant',
619
+ rootElement,
620
+ isDefault: true,
621
+ };
622
+
623
+ const masterComponent: MasterComponent = {
624
+ id,
625
+ name,
626
+ categoryId,
627
+ tags: [],
628
+ variants: [defaultVariant],
629
+ defaultVariantId: defaultVariant.id,
630
+ exposedProperties: [],
631
+ createdAt: now,
632
+ updatedAt: now,
633
+ websiteId: websiteId || '',
634
+ version: 1,
635
+ };
636
+
637
+ setMasterComponents((prev) => {
638
+ const next = new Map(prev);
639
+ next.set(id, masterComponent);
640
+ return next;
641
+ });
642
+
643
+ // Add to category
644
+ setComponentLibrary((prev) =>
645
+ prev.map((cat) =>
646
+ cat.id === categoryId
647
+ ? { ...cat, componentIds: [...cat.componentIds, id] }
648
+ : cat
649
+ )
650
+ );
651
+
652
+ // Save to Firestore asynchronously
653
+ if (websiteId) {
654
+ saveToFirestore(websiteId, masterComponent).catch((err) => {
655
+ console.error('[EditorComponentsContext] Failed to save component to Firestore:', err);
656
+ setError('Failed to save component');
657
+ });
658
+ }
659
+
660
+ console.log(
661
+ `[EditorComponentsContext] Created master component: ${name} (${id})`
662
+ );
663
+ return masterComponent;
664
+ },
665
+ [websiteId]
666
+ );
667
+
668
+ const updateMasterComponent = useCallback(
669
+ (id: string, updates: Partial<MasterComponent>) => {
670
+ setMasterComponents((prev) => {
671
+ const component = prev.get(id);
672
+ if (!component) {
673
+ console.warn(
674
+ `[EditorComponentsContext] Master component not found: ${id}`
675
+ );
676
+ return prev;
677
+ }
678
+
679
+ const updatedComponent = {
680
+ ...component,
681
+ ...updates,
682
+ updatedAt: new Date().toISOString(),
683
+ version: component.version + 1,
684
+ };
685
+
686
+ const next = new Map(prev);
687
+ next.set(id, updatedComponent);
688
+
689
+ // Save to Firestore asynchronously
690
+ if (websiteId) {
691
+ updateMasterComponentInFirestore(websiteId, id, updates).catch((err) => {
692
+ console.error('[EditorComponentsContext] Failed to update component in Firestore:', err);
693
+ setError('Failed to save component changes');
694
+ });
695
+ }
696
+
697
+ return next;
698
+ });
699
+ },
700
+ [websiteId]
701
+ );
702
+
703
+ const deleteMasterComponent = useCallback((id: string) => {
704
+ setMasterComponents((prev) => {
705
+ const next = new Map(prev);
706
+ next.delete(id);
707
+ return next;
708
+ });
709
+
710
+ // Remove from category
711
+ setComponentLibrary((prev) =>
712
+ prev.map((cat) => ({
713
+ ...cat,
714
+ componentIds: cat.componentIds.filter((cid) => cid !== id),
715
+ }))
716
+ );
717
+
718
+ // Delete all instances of this master
719
+ setComponentInstances((prev) => {
720
+ const next = new Map(prev);
721
+ for (const [instanceId, instance] of prev) {
722
+ if (instance.masterComponentId === id) {
723
+ next.delete(instanceId);
724
+ }
725
+ }
726
+ return next;
727
+ });
728
+
729
+ // Delete from Firestore asynchronously
730
+ if (websiteId) {
731
+ deleteFromFirestore(websiteId, id).catch((err) => {
732
+ console.error('[EditorComponentsContext] Failed to delete component from Firestore:', err);
733
+ });
734
+ }
735
+
736
+ console.log(`[EditorComponentsContext] Deleted master component: ${id}`);
737
+ }, [websiteId]);
738
+
739
+ const getMasterComponent = useCallback(
740
+ (id: string): MasterComponent | null => {
741
+ return masterComponents.get(id) || null;
742
+ },
743
+ [masterComponents]
744
+ );
745
+
746
+ // ============================================================
747
+ // Instance Operations
748
+ // ============================================================
749
+
750
+ const createInstance = useCallback(
751
+ (
752
+ masterComponentId: string,
753
+ variantId?: string,
754
+ instancePageId?: string,
755
+ /** Optional: pass the master component directly if it's not yet in state */
756
+ providedMaster?: MasterComponent,
757
+ /** Optional: specify domElementId (for duplication) */
758
+ customDomElementId?: string,
759
+ /** Optional: initial overrides to copy (for duplication) */
760
+ initialOverrides?: ComponentOverride[],
761
+ /** Optional: initial property values to copy (for duplication) */
762
+ initialPropertyValues?: Record<string, string | number | boolean>,
763
+ /** Optional: initial position for the instance */
764
+ initialPosition?: { x: number; y: number },
765
+ /** Optional: initial size for the instance */
766
+ initialSize?: { width: number; height: number }
767
+ ): ComponentInstance => {
768
+ const master = providedMaster || masterComponents.get(masterComponentId);
769
+ if (!master) {
770
+ throw new Error(
771
+ `Master component not found: ${masterComponentId}`
772
+ );
773
+ }
774
+
775
+ const id = generateId();
776
+ const domElementId = customDomElementId || `component-instance-${id}`;
777
+ const now = new Date().toISOString();
778
+ const effectivePageId = instancePageId || pageId;
779
+
780
+ const instance: ComponentInstance = {
781
+ id,
782
+ masterComponentId,
783
+ variantId: variantId || master.defaultVariantId,
784
+ domElementId,
785
+ overrides: initialOverrides ? [...initialOverrides] : [],
786
+ propertyValues: initialPropertyValues ? { ...initialPropertyValues } : {},
787
+ isDetached: false,
788
+ pageId: effectivePageId,
789
+ position: initialPosition,
790
+ size: initialSize,
791
+ createdAt: now,
792
+ updatedAt: now,
793
+ };
794
+
795
+ setComponentInstances((prev) => {
796
+ const next = new Map(prev);
797
+ next.set(domElementId, instance);
798
+ return next;
799
+ });
800
+
801
+ // Save to Firestore asynchronously
802
+ if (websiteId && effectivePageId) {
803
+ saveComponentInstance(websiteId, effectivePageId, instance).catch((err) => {
804
+ console.error('[EditorComponentsContext] Failed to save instance to Firestore:', err);
805
+ setError('Failed to save component instance');
806
+ });
807
+ }
808
+
809
+ console.log(
810
+ `[EditorComponentsContext] Created instance: ${id} of master ${masterComponentId}`
811
+ );
812
+ return instance;
813
+ },
814
+ [masterComponents, websiteId, pageId]
815
+ );
816
+
817
+ const updateInstance = useCallback(
818
+ (instanceId: string, updates: Partial<ComponentInstance>) => {
819
+ setComponentInstances((prev) => {
820
+ // Find instance by ID (not domElementId)
821
+ let targetKey: string | null = null;
822
+ let foundInstance: ComponentInstance | null = null;
823
+ for (const [key, instance] of prev) {
824
+ if (instance.id === instanceId) {
825
+ targetKey = key;
826
+ foundInstance = instance;
827
+ break;
828
+ }
829
+ }
830
+
831
+ if (!targetKey || !foundInstance) {
832
+ console.warn(
833
+ `[EditorComponentsContext] Instance not found: ${instanceId}`
834
+ );
835
+ return prev;
836
+ }
837
+
838
+ const updatedInstance = {
839
+ ...foundInstance,
840
+ ...updates,
841
+ updatedAt: new Date().toISOString(),
842
+ };
843
+
844
+ const next = new Map(prev);
845
+ next.set(targetKey, updatedInstance);
846
+
847
+ // Save to Firestore asynchronously
848
+ const instancePageId = updatedInstance.pageId || pageId;
849
+ if (websiteId && instancePageId) {
850
+ updateComponentInstance(websiteId, instancePageId, instanceId, updates).catch((err) => {
851
+ console.error('[EditorComponentsContext] Failed to update instance in Firestore:', err);
852
+ setError('Failed to save instance changes');
853
+ });
854
+ }
855
+
856
+ return next;
857
+ });
858
+ },
859
+ [websiteId, pageId]
860
+ );
861
+
862
+ const deleteInstance = useCallback((instanceId: string) => {
863
+ setComponentInstances((prev) => {
864
+ // Find instance by ID
865
+ let targetKey: string | null = null;
866
+ let foundInstance: ComponentInstance | null = null;
867
+ for (const [key, instance] of prev) {
868
+ if (instance.id === instanceId) {
869
+ targetKey = key;
870
+ foundInstance = instance;
871
+ break;
872
+ }
873
+ }
874
+
875
+ if (!targetKey || !foundInstance) {
876
+ console.warn(
877
+ `[EditorComponentsContext] Instance not found for deletion: ${instanceId}`
878
+ );
879
+ return prev;
880
+ }
881
+
882
+ const next = new Map(prev);
883
+ next.delete(targetKey);
884
+
885
+ // Delete from Firestore asynchronously
886
+ const instancePageId = foundInstance.pageId || pageId;
887
+ if (websiteId && instancePageId) {
888
+ deleteComponentInstance(websiteId, instancePageId, instanceId).catch((err) => {
889
+ console.error('[EditorComponentsContext] Failed to delete instance from Firestore:', err);
890
+ setError('Failed to delete component instance');
891
+ });
892
+ }
893
+
894
+ return next;
895
+ });
896
+
897
+ console.log(`[EditorComponentsContext] Deleted instance: ${instanceId}`);
898
+ }, [websiteId, pageId]);
899
+
900
+ const getInstanceByDomId = useCallback(
901
+ (domElementId: string): ComponentInstance | null => {
902
+ return componentInstances.get(domElementId) || null;
903
+ },
904
+ [componentInstances]
905
+ );
906
+
907
+ // ============================================================
908
+ // Override Operations
909
+ // ============================================================
910
+
911
+ const addOverride = useCallback(
912
+ (instanceId: string, override: Omit<ComponentOverride, 'id'>) => {
913
+ const newOverride: ComponentOverride = {
914
+ ...override,
915
+ id: generateId(),
916
+ };
917
+
918
+ setComponentInstances((prev) => {
919
+ // Find instance by ID
920
+ let targetKey: string | null = null;
921
+ let foundInstance: ComponentInstance | null = null;
922
+ for (const [key, instance] of prev) {
923
+ if (instance.id === instanceId) {
924
+ targetKey = key;
925
+ foundInstance = instance;
926
+ break;
927
+ }
928
+ }
929
+
930
+ if (!targetKey || !foundInstance) {
931
+ console.warn(
932
+ `[EditorComponentsContext] Instance not found for override: ${instanceId}`
933
+ );
934
+ return prev;
935
+ }
936
+
937
+ const newOverrides = [...foundInstance.overrides, newOverride];
938
+ const updatedInstance = {
939
+ ...foundInstance,
940
+ overrides: newOverrides,
941
+ updatedAt: new Date().toISOString(),
942
+ };
943
+
944
+ const next = new Map(prev);
945
+ next.set(targetKey, updatedInstance);
946
+
947
+ // Save to Firestore asynchronously
948
+ const instancePageId = foundInstance.pageId || pageId;
949
+ if (websiteId && instancePageId) {
950
+ updateComponentInstance(websiteId, instancePageId, instanceId, { overrides: newOverrides }).catch((err) => {
951
+ console.error('[EditorComponentsContext] Failed to save override to Firestore:', err);
952
+ setError('Failed to save override');
953
+ });
954
+ }
955
+
956
+ return next;
957
+ });
958
+
959
+ console.log(
960
+ `[EditorComponentsContext] Added override to instance: ${instanceId}`
961
+ );
962
+ },
963
+ [websiteId, pageId]
964
+ );
965
+
966
+ const removeOverride = useCallback(
967
+ (instanceId: string, overrideId: string) => {
968
+ setComponentInstances((prev) => {
969
+ // Find instance by ID
970
+ let targetKey: string | null = null;
971
+ let foundInstance: ComponentInstance | null = null;
972
+ for (const [key, instance] of prev) {
973
+ if (instance.id === instanceId) {
974
+ targetKey = key;
975
+ foundInstance = instance;
976
+ break;
977
+ }
978
+ }
979
+
980
+ if (!targetKey || !foundInstance) {
981
+ console.warn(
982
+ `[EditorComponentsContext] Instance not found for override removal: ${instanceId}`
983
+ );
984
+ return prev;
985
+ }
986
+
987
+ const newOverrides = foundInstance.overrides.filter((o) => o.id !== overrideId);
988
+ const updatedInstance = {
989
+ ...foundInstance,
990
+ overrides: newOverrides,
991
+ updatedAt: new Date().toISOString(),
992
+ };
993
+
994
+ const next = new Map(prev);
995
+ next.set(targetKey, updatedInstance);
996
+
997
+ // Save to Firestore asynchronously
998
+ const instancePageId = foundInstance.pageId || pageId;
999
+ if (websiteId && instancePageId) {
1000
+ updateComponentInstance(websiteId, instancePageId, instanceId, { overrides: newOverrides }).catch((err) => {
1001
+ console.error('[EditorComponentsContext] Failed to remove override in Firestore:', err);
1002
+ setError('Failed to remove override');
1003
+ });
1004
+ }
1005
+
1006
+ return next;
1007
+ });
1008
+
1009
+ console.log(
1010
+ `[EditorComponentsContext] Removed override ${overrideId} from instance: ${instanceId}`
1011
+ );
1012
+ },
1013
+ [websiteId, pageId]
1014
+ );
1015
+
1016
+ const resetAllOverrides = useCallback((instanceId: string) => {
1017
+ setComponentInstances((prev) => {
1018
+ // Find instance by ID
1019
+ let targetKey: string | null = null;
1020
+ let foundInstance: ComponentInstance | null = null;
1021
+ for (const [key, instance] of prev) {
1022
+ if (instance.id === instanceId) {
1023
+ targetKey = key;
1024
+ foundInstance = instance;
1025
+ break;
1026
+ }
1027
+ }
1028
+
1029
+ if (!targetKey || !foundInstance) {
1030
+ console.warn(
1031
+ `[EditorComponentsContext] Instance not found for reset: ${instanceId}`
1032
+ );
1033
+ return prev;
1034
+ }
1035
+
1036
+ const updatedInstance = {
1037
+ ...foundInstance,
1038
+ overrides: [],
1039
+ propertyValues: {},
1040
+ updatedAt: new Date().toISOString(),
1041
+ };
1042
+
1043
+ const next = new Map(prev);
1044
+ next.set(targetKey, updatedInstance);
1045
+
1046
+ // Save to Firestore asynchronously
1047
+ const instancePageId = foundInstance.pageId || pageId;
1048
+ if (websiteId && instancePageId) {
1049
+ updateComponentInstance(websiteId, instancePageId, instanceId, {
1050
+ overrides: [],
1051
+ propertyValues: {}
1052
+ }).catch((err) => {
1053
+ console.error('[EditorComponentsContext] Failed to reset overrides in Firestore:', err);
1054
+ setError('Failed to reset overrides');
1055
+ });
1056
+ }
1057
+
1058
+ return next;
1059
+ });
1060
+
1061
+ console.log(
1062
+ `[EditorComponentsContext] Reset all overrides for instance: ${instanceId}`
1063
+ );
1064
+ }, [websiteId, pageId]);
1065
+
1066
+ // ============================================================
1067
+ // Variant Operations
1068
+ // ============================================================
1069
+
1070
+ const changeVariant = useCallback(
1071
+ (instanceId: string, variantId: string) => {
1072
+ setComponentInstances((prev) => {
1073
+ // Find instance by ID
1074
+ let targetKey: string | null = null;
1075
+ let foundInstance: ComponentInstance | null = null;
1076
+ for (const [key, instance] of prev) {
1077
+ if (instance.id === instanceId) {
1078
+ targetKey = key;
1079
+ foundInstance = instance;
1080
+ break;
1081
+ }
1082
+ }
1083
+
1084
+ if (!targetKey || !foundInstance) {
1085
+ console.warn(
1086
+ `[EditorComponentsContext] Instance not found for variant change: ${instanceId}`
1087
+ );
1088
+ return prev;
1089
+ }
1090
+
1091
+ // Verify variant exists in master
1092
+ const master = masterComponents.get(foundInstance.masterComponentId);
1093
+ if (!master) {
1094
+ console.warn(
1095
+ `[EditorComponentsContext] Master component not found: ${foundInstance.masterComponentId}`
1096
+ );
1097
+ return prev;
1098
+ }
1099
+
1100
+ const variantExists = master.variants.some((v) => v.id === variantId);
1101
+ if (!variantExists) {
1102
+ console.warn(
1103
+ `[EditorComponentsContext] Variant not found: ${variantId}`
1104
+ );
1105
+ return prev;
1106
+ }
1107
+
1108
+ const updatedInstance = {
1109
+ ...foundInstance,
1110
+ variantId,
1111
+ // Reset overrides when changing variants (optional, could be configurable)
1112
+ overrides: [],
1113
+ updatedAt: new Date().toISOString(),
1114
+ };
1115
+
1116
+ const next = new Map(prev);
1117
+ next.set(targetKey, updatedInstance);
1118
+
1119
+ // Save to Firestore asynchronously
1120
+ const instancePageId = foundInstance.pageId || pageId;
1121
+ if (websiteId && instancePageId) {
1122
+ updateComponentInstance(websiteId, instancePageId, instanceId, {
1123
+ variantId,
1124
+ overrides: []
1125
+ }).catch((err) => {
1126
+ console.error('[EditorComponentsContext] Failed to change variant in Firestore:', err);
1127
+ setError('Failed to change variant');
1128
+ });
1129
+ }
1130
+
1131
+ return next;
1132
+ });
1133
+
1134
+ console.log(
1135
+ `[EditorComponentsContext] Changed variant for instance ${instanceId} to ${variantId}`
1136
+ );
1137
+ },
1138
+ [masterComponents, websiteId, pageId]
1139
+ );
1140
+
1141
+ // ============================================================
1142
+ // Detach Operation
1143
+ // ============================================================
1144
+
1145
+ const detachInstance = useCallback(
1146
+ (instanceId: string): HTMLElement => {
1147
+ // Find instance
1148
+ let instance: ComponentInstance | null = null;
1149
+ let targetKey: string | null = null;
1150
+ for (const [key, inst] of componentInstances) {
1151
+ if (inst.id === instanceId) {
1152
+ instance = inst;
1153
+ targetKey = key;
1154
+ break;
1155
+ }
1156
+ }
1157
+
1158
+ if (!instance || !targetKey) {
1159
+ throw new Error(`Instance not found: ${instanceId}`);
1160
+ }
1161
+
1162
+ // Get master component
1163
+ const master = masterComponents.get(instance.masterComponentId);
1164
+ if (!master) {
1165
+ throw new Error(
1166
+ `Master component not found: ${instance.masterComponentId}`
1167
+ );
1168
+ }
1169
+
1170
+ // Resolve the instance to get the final element
1171
+ const resolved = resolveInstance(master, instance);
1172
+
1173
+ // Convert to HTMLElement
1174
+ const htmlElement = componentElementToHtml(resolved);
1175
+
1176
+ // Mark instance as detached
1177
+ setComponentInstances((prev) => {
1178
+ const next = new Map(prev);
1179
+ next.set(targetKey!, {
1180
+ ...instance!,
1181
+ isDetached: true,
1182
+ updatedAt: new Date().toISOString(),
1183
+ });
1184
+ return next;
1185
+ });
1186
+
1187
+ // Save to Firestore asynchronously
1188
+ const instancePageId = instance.pageId || pageId;
1189
+ if (websiteId && instancePageId) {
1190
+ updateComponentInstance(websiteId, instancePageId, instanceId, {
1191
+ isDetached: true
1192
+ }).catch((err) => {
1193
+ console.error('[EditorComponentsContext] Failed to detach instance in Firestore:', err);
1194
+ setError('Failed to detach instance');
1195
+ });
1196
+ }
1197
+
1198
+ console.log(
1199
+ `[EditorComponentsContext] Detached instance: ${instanceId}`
1200
+ );
1201
+ return htmlElement;
1202
+ },
1203
+ [componentInstances, masterComponents, websiteId, pageId]
1204
+ );
1205
+
1206
+ // ============================================================
1207
+ // Resolution
1208
+ // ============================================================
1209
+
1210
+ const resolveInstance = useCallback(
1211
+ (master: MasterComponent, instance: ComponentInstance): ComponentElement => {
1212
+ // Find the variant
1213
+ const variant = master.variants.find((v) => v.id === instance.variantId);
1214
+ if (!variant) {
1215
+ console.warn(
1216
+ `[EditorComponentsContext] Variant not found: ${instance.variantId}, using default`
1217
+ );
1218
+ const defaultVariant = master.variants.find(
1219
+ (v) => v.id === master.defaultVariantId
1220
+ );
1221
+ if (!defaultVariant) {
1222
+ throw new Error(
1223
+ `No variants found for master component: ${master.id}`
1224
+ );
1225
+ }
1226
+ return deepCloneElement(defaultVariant.rootElement);
1227
+ }
1228
+
1229
+ // Deep clone the variant's root element
1230
+ const resolved = deepCloneElement(variant.rootElement);
1231
+
1232
+ // Set the instance's DOM element ID
1233
+ resolved.id = instance.domElementId;
1234
+
1235
+ // Add data attribute to identify as component instance
1236
+ resolved.attributes['data-component-instance'] = instance.id;
1237
+ resolved.attributes['data-component-master'] = master.id;
1238
+
1239
+ // Apply all overrides
1240
+ for (const override of instance.overrides) {
1241
+ applyOverrideToElement(resolved, override);
1242
+ }
1243
+
1244
+ return resolved;
1245
+ },
1246
+ []
1247
+ );
1248
+
1249
+ // ============================================================
1250
+ // Context Value
1251
+ // ============================================================
1252
+
1253
+ const value: EditorComponentsContextValue = useMemo(
1254
+ () => ({
1255
+ // State
1256
+ masterComponents,
1257
+ componentInstances,
1258
+ componentLibrary,
1259
+ isLoadingComponents,
1260
+ selectedMasterComponentId,
1261
+ selectedInstanceId,
1262
+ error,
1263
+ pendingNavigationTarget,
1264
+
1265
+ // Actions
1266
+ loadComponents,
1267
+ createMasterComponent,
1268
+ updateMasterComponent,
1269
+ deleteMasterComponent,
1270
+ getMasterComponent,
1271
+ createInstance,
1272
+ updateInstance,
1273
+ deleteInstance,
1274
+ getInstanceByDomId,
1275
+ addOverride,
1276
+ removeOverride,
1277
+ resetAllOverrides,
1278
+ changeVariant,
1279
+ detachInstance,
1280
+ resolveInstance,
1281
+ navigateToMasterComponent,
1282
+ clearNavigationRequest,
1283
+ }),
1284
+ [
1285
+ masterComponents,
1286
+ componentInstances,
1287
+ componentLibrary,
1288
+ isLoadingComponents,
1289
+ selectedMasterComponentId,
1290
+ selectedInstanceId,
1291
+ error,
1292
+ pendingNavigationTarget,
1293
+ loadComponents,
1294
+ createMasterComponent,
1295
+ updateMasterComponent,
1296
+ deleteMasterComponent,
1297
+ getMasterComponent,
1298
+ createInstance,
1299
+ updateInstance,
1300
+ deleteInstance,
1301
+ getInstanceByDomId,
1302
+ addOverride,
1303
+ removeOverride,
1304
+ resetAllOverrides,
1305
+ changeVariant,
1306
+ detachInstance,
1307
+ resolveInstance,
1308
+ navigateToMasterComponent,
1309
+ clearNavigationRequest,
1310
+ ]
1311
+ );
1312
+
1313
+ return (
1314
+ <EditorComponentsContext.Provider value={value}>
1315
+ {children}
1316
+ </EditorComponentsContext.Provider>
1317
+ );
1318
+ }
1319
+
1320
+ export default EditorComponentsContext;