editor-ts 0.0.10 → 0.0.12

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/src/core/init.ts CHANGED
@@ -4,7 +4,15 @@
4
4
  */
5
5
 
6
6
  import { Page } from './Page';
7
- import type { InitConfig, EditorTsEditor, Component } from '../types';
7
+ import { LayerManager } from './LayerManager';
8
+ import { ComponentPalette } from './ComponentPalette';
9
+ import { StorageManager } from './StorageManager';
10
+ import { VersionControl } from './VersionControl';
11
+ import { KeyboardShortcuts, createCommandPaletteShortcuts, createDefaultShortcuts, createEditorShortcuts, type ShortcutContext } from './KeyboardShortcuts';
12
+ import { defaultComponentRegistry, mergeCustomComponentRegistry } from './CustomComponentRegistry';
13
+ import { buildIframeCanvasSrcdocFromPage } from './iframeCanvas';
14
+ import { applyAiReplacementsToPage, normalizeOpencodeModelId, requestAiReplacements } from './aiChat';
15
+ import type { InitConfig, EditorTsEditor, Component, PageData, MultiPageData, EditorTsAiModule, OpencodeAiProviderConfig, AiProviderMode, EditorTsEventMap, EditorTsEventName, PagesRenderProps } from '../types';
8
16
 
9
17
  /**
10
18
  * Initialize EditorTs Editor
@@ -16,9 +24,90 @@ export function init(config: InitConfig): EditorTsEditor {
16
24
  if (!iframe || iframe.tagName !== 'IFRAME') {
17
25
  throw new Error(`Iframe element #${config.iframeId} not found or is not an iframe`);
18
26
  }
27
+ const vimMode = config.vimMode ?? false;
28
+
29
+ const isMultiPageData = (data: PageData | MultiPageData): data is MultiPageData => {
30
+ return !!data && typeof data === 'object' && Array.isArray((data as MultiPageData).pages);
31
+ };
32
+
33
+ const rawData: PageData | MultiPageData =
34
+ typeof config.data === 'string' ? (JSON.parse(config.data) as PageData | MultiPageData) : config.data;
35
+ let multiPageData: MultiPageData | null = null;
36
+ let activePageIndex = 0;
37
+
38
+ let initialPageData: PageData;
39
+ if (isMultiPageData(rawData)) {
40
+ if (rawData.pages.length === 0) {
41
+ throw new Error('MultiPageData.pages cannot be empty');
42
+ }
43
+
44
+ multiPageData = rawData;
45
+ activePageIndex = rawData.activePageIndex ?? 0;
46
+ initialPageData = rawData.pages[activePageIndex] ?? rawData.pages[0]!;
47
+ } else {
48
+ initialPageData = rawData as PageData;
49
+ }
50
+
51
+ const componentRegistry = mergeCustomComponentRegistry(defaultComponentRegistry, config.customComponents);
52
+
53
+ const resolveComponents = (components: Component[]): Component[] => {
54
+ const resolveComponent = (component: Component): Component => {
55
+ const def = componentRegistry[component.type];
56
+
57
+ const isStub =
58
+ component.tagName === undefined &&
59
+ component.components === undefined &&
60
+ component.content === undefined &&
61
+ component.script === undefined &&
62
+ component.style === undefined;
63
+
64
+ const base = def && isStub ? def.factory() : component;
65
+
66
+ const mergedAttributes = {
67
+ ...(base.attributes ?? {}),
68
+ ...(component.attributes ?? {}),
69
+ };
70
+
71
+ const next: Component = {
72
+ ...base,
73
+ ...component,
74
+ attributes: mergedAttributes,
75
+ };
76
+
77
+ if (next.components && next.components.length > 0) {
78
+ next.components = next.components.map(resolveComponent);
79
+ }
80
+
81
+ return next;
82
+ };
83
+
84
+ return components.map(resolveComponent);
85
+ };
86
+
87
+ const resolvePageData = (data: PageData): PageData => {
88
+ const raw = data.body.components;
89
+
90
+ if (Array.isArray(raw)) {
91
+ data.body.components = resolveComponents(raw);
92
+ } else if (typeof raw === 'string') {
93
+ try {
94
+ const parsed = JSON.parse(raw) as Component[];
95
+ data.body.components = JSON.stringify(resolveComponents(parsed));
96
+ } catch {
97
+ // ignore
98
+ }
99
+ }
100
+
101
+ return data;
102
+ };
103
+
104
+ const resolvedInitialPageData = resolvePageData(initialPageData);
105
+ if (multiPageData) {
106
+ multiPageData.pages[activePageIndex] = resolvedInitialPageData;
107
+ }
19
108
 
20
109
  // Create Page instance
21
- const page = new Page(config.data);
110
+ const page = new Page(resolvedInitialPageData);
22
111
 
23
112
  // Configure toolbars from config
24
113
  if (config.toolbars) {
@@ -46,221 +135,342 @@ export function init(config: InitConfig): EditorTsEditor {
46
135
  }
47
136
 
48
137
  // Event system
49
- const eventListeners: Record<string, Function[]> = {};
138
+ type AnyEditorEventArgs = EditorTsEventMap[EditorTsEventName];
139
+ type AnyEditorEventCallback = (...args: AnyEditorEventArgs) => void;
140
+
141
+ const eventListeners: Partial<Record<EditorTsEventName, AnyEditorEventCallback[]>> = {};
50
142
 
51
- const on = (event: string, callback: Function) => {
143
+ const on = <K extends EditorTsEventName>(event: K, callback: (...args: EditorTsEventMap[K]) => void) => {
52
144
  if (!eventListeners[event]) {
53
145
  eventListeners[event] = [];
54
146
  }
55
- eventListeners[event]!.push(callback);
147
+ eventListeners[event]!.push(callback as AnyEditorEventCallback);
56
148
  };
57
149
 
58
- const off = (event: string, callback: Function) => {
150
+ const off = <K extends EditorTsEventName>(event: K, callback: (...args: EditorTsEventMap[K]) => void) => {
59
151
  if (eventListeners[event]) {
60
- eventListeners[event] = eventListeners[event]!.filter(cb => cb !== callback);
152
+ eventListeners[event] = eventListeners[event]!.filter((cb) => cb !== (callback as AnyEditorEventCallback));
61
153
  }
62
154
  };
63
155
 
64
- const emit = (event: string, ...args: any[]) => {
65
- if (eventListeners[event]) {
66
- eventListeners[event]!.forEach(callback => callback(...args));
67
- }
156
+ const emit = <K extends EditorTsEventName>(event: K, ...args: EditorTsEventMap[K]) => {
157
+ eventListeners[event]?.forEach((callback) => callback(...(args as AnyEditorEventArgs)));
68
158
  };
69
159
 
70
160
  // Get optional UI containers
71
- const sidebarContainer = config.ui?.sidebar?.containerId
72
- ? document.getElementById(config.ui.sidebar.containerId)
161
+ const sidebarContainer = config.ui?.sidebar?.containerId
162
+ ? document.getElementById(config.ui.sidebar.containerId)
73
163
  : null;
74
-
164
+
165
+ // Optional AI UI (user-provided elements)
166
+ const aiChatConfig = config.ui?.aiChat;
167
+ const shouldEnableAiChatUi = !!aiChatConfig && aiChatConfig.enabled !== false;
168
+
169
+ const aiChatRoot = shouldEnableAiChatUi
170
+ ? (aiChatConfig?.rootId ? document.getElementById(aiChatConfig.rootId) : null)
171
+ : null;
172
+
173
+ const aiChatExpandButton = shouldEnableAiChatUi && aiChatConfig?.expandButtonId
174
+ ? document.getElementById(aiChatConfig.expandButtonId)
175
+ : null;
176
+
177
+ const aiBaseUrlInput = shouldEnableAiChatUi && aiChatConfig?.baseUrlInputId
178
+ ? (document.getElementById(aiChatConfig.baseUrlInputId) as HTMLInputElement | null)
179
+ : null;
180
+
181
+ const aiChatInput = shouldEnableAiChatUi && aiChatConfig?.inputId
182
+ ? (document.getElementById(aiChatConfig.inputId) as HTMLTextAreaElement | null)
183
+ : null;
184
+
185
+ const aiChatSendButton = shouldEnableAiChatUi && aiChatConfig?.sendButtonId
186
+ ? (document.getElementById(aiChatConfig.sendButtonId) as HTMLButtonElement | null)
187
+ : null;
188
+
189
+ const aiChatApplyButton = shouldEnableAiChatUi && aiChatConfig?.applyButtonId
190
+ ? (document.getElementById(aiChatConfig.applyButtonId) as HTMLButtonElement | null)
191
+ : null;
192
+
193
+ const aiChatLog = shouldEnableAiChatUi && aiChatConfig?.logId
194
+ ? (document.getElementById(aiChatConfig.logId) as HTMLElement | null)
195
+ : null;
196
+
197
+ const aiChatLinkAnchor = shouldEnableAiChatUi && aiChatConfig?.link?.enabled !== false && aiChatConfig?.link?.anchorId
198
+ ? (document.getElementById(aiChatConfig.link.anchorId) as HTMLAnchorElement | null)
199
+ : null;
200
+
201
+ const aiSessionSelect = shouldEnableAiChatUi && aiChatConfig?.sessionSelectId
202
+ ? (document.getElementById(aiChatConfig.sessionSelectId) as HTMLSelectElement | null)
203
+ : null;
204
+ const aiModelSelect = shouldEnableAiChatUi && aiChatConfig?.modelSelectId
205
+ ? (document.getElementById(aiChatConfig.modelSelectId) as HTMLSelectElement | null)
206
+ : null;
207
+ const aiSessionNewButton = shouldEnableAiChatUi && aiChatConfig?.sessionNewButtonId
208
+ ? (document.getElementById(aiChatConfig.sessionNewButtonId) as HTMLButtonElement | null)
209
+ : null;
210
+
211
+
212
+ const aiHealthButton = shouldEnableAiChatUi && aiChatConfig?.healthButtonId
213
+ ? (document.getElementById(aiChatConfig.healthButtonId) as HTMLButtonElement | null)
214
+ : null;
215
+
216
+ const aiHealthStatus = shouldEnableAiChatUi && aiChatConfig?.healthStatusId
217
+ ? (document.getElementById(aiChatConfig.healthStatusId) as HTMLElement | null)
218
+ : null;
219
+
220
+ // If the host app serves the editor and can proxy requests, prefer that to avoid
221
+ // CORS preflight issues with password-protected opencode servers.
222
+ const aiProxiedBaseUrl = `${window.location.origin}/opencode`;
223
+
75
224
  const statsContainer = config.ui?.stats?.containerId
76
225
  ? document.getElementById(config.ui.stats.containerId)
77
226
  : null;
78
-
227
+
79
228
  const selectedInfoContainer = config.ui?.selectedInfo?.containerId
80
229
  ? document.getElementById(config.ui.selectedInfo.containerId)
81
230
  : null;
82
231
 
83
- // Populate stats if container provided
84
- if (statsContainer && config.ui?.stats?.enabled !== false) {
85
- statsContainer.innerHTML = `
86
- <div style="font-size: 0.85rem;">
87
- <div>Components: ${page.components.count()}</div>
88
- <div>Styles: ${page.styles.count()}</div>
89
- <div>Assets: ${page.assets.count()}</div>
90
- </div>
91
- `;
92
- }
232
+ const layersContainer = config.ui?.layers?.containerId
233
+ ? document.getElementById(config.ui.layers.containerId)
234
+ : null;
93
235
 
94
- // Build iframe content with WYSIWYG
95
- const iframeContent = `<!DOCTYPE html>
96
- <html>
97
- <head>
98
- <meta charset="UTF-8">
99
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
100
- <title>${page.getTitle()}</title>
101
- <style>${page.getCSS()}</style>
102
- <style>
103
- /* WYSIWYG editing styles */
104
- .editorts-highlight {
105
- outline: 2px dashed var(--color-editor-light-text, #212C3E) !important;
106
- outline-offset: 2px;
107
- cursor: pointer !important;
108
- position: relative !important;
109
- }
110
- .editorts-highlight:hover {
111
- outline: 2px solid var(--color-editor-light-text, #212C3E) !important;
112
- background-color: rgba(33, 44, 62, 0.05) !important;
113
- }
114
- .editorts-selected {
115
- outline: 3px solid #10b981 !important;
116
- background-color: rgba(16, 185, 129, 0.1) !important;
117
- }
118
- .editorts-context-toolbar {
119
- position: absolute;
120
- top: -42px;
121
- left: 0;
122
- background: white;
123
- border: 2px solid var(--color-editor-light-text, #212C3E);
124
- border-radius: 6px;
125
- padding: 0.4rem;
126
- display: flex;
127
- gap: 0.3rem;
128
- box-shadow: 0 4px 20px rgba(0,0,0,0.25);
129
- z-index: 9999;
130
- }
131
- .toolbar-action {
132
- background: white;
133
- border: 1px solid var(--color-primary-border, #e5e7eb);
134
- padding: 0.5rem 0.75rem;
135
- border-radius: 4px;
136
- cursor: pointer;
137
- font-size: 0.85rem;
138
- white-space: nowrap;
139
- font-family: var(--font-main);
140
- transition: all 0.2s;
141
- }
142
- .toolbar-action:hover {
143
- background: var(--color-editor-light-bg, #EDF0F5);
144
- border-color: var(--color-editor-light-text, #212C3E);
145
- }
146
- .toolbar-action.danger:hover {
147
- background: #fee;
148
- border-color: #ef4444;
149
- color: #ef4444;
150
- }
151
- </style>
152
- </head>
153
- ${page.getHTML()}
154
- <script>
155
- let selectedElement = null;
156
-
157
- // Initialize WYSIWYG
158
- function initWYSIWYG() {
159
- document.querySelectorAll('[id]').forEach(el => {
160
- if (!el.id || el.id.startsWith('editorts-')) return;
161
-
162
- el.classList.add('editorts-highlight');
163
-
164
- el.addEventListener('click', (e) => {
165
- e.stopPropagation();
166
- selectElement(el);
167
- });
168
- });
169
- }
236
+ const pagesContainer = config.ui?.pages?.containerId
237
+ ? document.getElementById(config.ui.pages.containerId)
238
+ : null;
170
239
 
171
- function selectElement(el) {
172
- // Clear previous selection
173
- if (selectedElement) {
174
- selectedElement.classList.remove('editorts-selected');
175
- const oldToolbar = selectedElement.querySelector('.editorts-context-toolbar');
176
- if (oldToolbar) oldToolbar.remove();
177
- }
240
+ const shouldEnablePages = !!pagesContainer && config.ui?.pages?.enabled !== false;
178
241
 
179
- // Highlight new selection
180
- selectedElement = el;
181
- el.classList.add('editorts-selected');
242
+ const componentPaletteContainer = config.ui?.componentPalette?.containerId
243
+ ? document.getElementById(config.ui.componentPalette.containerId)
244
+ : null;
182
245
 
183
- // Notify parent
184
- window.parent.postMessage({
185
- type: 'editorts:componentSelected',
186
- id: el.id,
187
- tagName: el.tagName.toLowerCase(),
188
- className: el.className
189
- }, '*');
246
+ const autoSaveProgressBar = config.ui?.autoSave?.progressBarId
247
+ ? (document.getElementById(config.ui.autoSave.progressBarId) as HTMLElement | null)
248
+ : null;
190
249
 
191
- // Request toolbar config
192
- window.parent.postMessage({
193
- type: 'editorts:getToolbar',
194
- id: el.id
195
- }, '*');
196
- }
250
+ const commandPaletteContainer = config.ui?.commandPalette?.containerId
251
+ ? (document.getElementById(config.ui.commandPalette.containerId) as HTMLElement | null)
252
+ : null;
253
+ const commandPaletteInput = config.ui?.commandPalette?.inputId
254
+ ? (document.getElementById(config.ui.commandPalette.inputId) as HTMLInputElement | null)
255
+ : null;
256
+ const commandPaletteResults = config.ui?.commandPalette?.resultsId
257
+ ? (document.getElementById(config.ui.commandPalette.resultsId) as HTMLElement | null)
258
+ : null;
259
+ const commandPaletteClose = config.ui?.commandPalette?.closeButtonId
260
+ ? (document.getElementById(config.ui.commandPalette.closeButtonId) as HTMLButtonElement | null)
261
+ : null;
262
+ const commandPaletteHint = config.ui?.commandPalette?.hintId
263
+ ? (document.getElementById(config.ui.commandPalette.hintId) as HTMLElement | null)
264
+ : null;
197
265
 
198
- // Listen for toolbar config from parent
199
- window.addEventListener('message', (event) => {
200
- if (event.data.type === 'editorts:toolbarConfig') {
201
- renderToolbar(event.data.config, event.data.elementId);
202
- } else if (event.data.type === 'editorts:toolbarAction') {
203
- handleToolbarAction(event.data.action, event.data.elementId);
204
- }
205
- });
266
+ // Optional code editor containers
267
+ const jsEditorContainer = config.ui?.editors?.js?.containerId
268
+ ? document.getElementById(config.ui.editors.js.containerId)
269
+ : null;
270
+ const cssEditorContainer = config.ui?.editors?.css?.containerId
271
+ ? document.getElementById(config.ui.editors.css.containerId)
272
+ : null;
273
+ const jsonEditorContainer = config.ui?.editors?.json?.containerId
274
+ ? document.getElementById(config.ui.editors.json.containerId)
275
+ : null;
276
+ const jsxEditorContainer = config.ui?.editors?.jsx?.containerId
277
+ ? document.getElementById(config.ui.editors.jsx.containerId)
278
+ : null;
279
+ const filesViewerContainer = config.ui?.editors?.files?.containerId
280
+ ? document.getElementById(config.ui.editors.files.containerId)
281
+ : null;
282
+ const viewerEditorContainer = config.ui?.editors?.viewer?.containerId
283
+ ? document.getElementById(config.ui.editors.viewer.containerId)
284
+ : null;
206
285
 
207
- function renderToolbar(toolbarConfig, elementId) {
208
- const el = document.getElementById(elementId);
209
- if (!el || !toolbarConfig.enabled) return;
210
286
 
211
- const toolbar = document.createElement('div');
212
- toolbar.className = 'editorts-context-toolbar';
287
+ // Optional: tabbed view toggle between canvas + code panels
288
+ // This does not create UI; it only wires existing buttons.
289
+ type CodeTab = 'files' | 'viewer' | 'js' | 'css' | 'json' | 'jsx';
213
290
 
214
- const enabledActions = toolbarConfig.actions.filter(a => a.enabled);
215
- enabledActions.forEach(action => {
216
- const btn = document.createElement('button');
217
- btn.className = 'toolbar-action' + (action.danger ? ' danger' : '');
218
- btn.textContent = action.icon + ' ' + action.label;
219
- btn.onclick = () => {
220
- window.parent.postMessage({
221
- type: 'editorts:toolbarAction',
222
- action: action.id,
223
- elementId: elementId
224
- }, '*');
291
+ let setView: ((view: 'editor' | 'code') => void) | null = null;
292
+ let setCodeTab: ((tab: CodeTab) => void) | null = null;
293
+
294
+ // Avoid TDZ by deferring workspace-dependent hooks until after the
295
+ // workspace variables are initialized later in init().
296
+ let codeTabHooksReady = false;
297
+
298
+ const onCodeTabChange = (tab: CodeTab) => {
299
+ if (!codeTabHooksReady) return;
300
+ if (tab !== 'files') return;
301
+
302
+ void (async () => {
303
+ if (workspaceEnabled && !workspace) {
304
+ try {
305
+ const mod = await import('modern-monaco');
306
+ await ensureWorkspace(mod);
307
+ } catch (err: unknown) {
308
+ const message = err instanceof Error ? err.message : String(err);
309
+ console.warn('Failed to load modern-monaco workspace:', message);
310
+ return;
311
+ }
312
+ }
313
+
314
+ await syncWorkspaceFiles();
315
+ await renderFilesList();
316
+ })();
317
+ };
318
+
319
+ const viewTabs = config.ui?.viewTabs;
320
+ if (viewTabs) {
321
+ const codeViewContainers = [filesViewerContainer, viewerEditorContainer, jsEditorContainer, cssEditorContainer, jsonEditorContainer, jsxEditorContainer]
322
+ .filter(Boolean) as HTMLElement[];
323
+
324
+ const codeTabs = config.ui?.codeTabs;
325
+
326
+ const codeTabButtons: Record<CodeTab, HTMLElement | null> = {
327
+ files: codeTabs?.filesButtonId ? document.getElementById(codeTabs.filesButtonId) : null,
328
+ viewer: codeTabs?.viewerButtonId ? document.getElementById(codeTabs.viewerButtonId) : null,
329
+ js: codeTabs?.jsButtonId ? document.getElementById(codeTabs.jsButtonId) : null,
330
+ css: codeTabs?.cssButtonId ? document.getElementById(codeTabs.cssButtonId) : null,
331
+ json: codeTabs?.jsonButtonId ? document.getElementById(codeTabs.jsonButtonId) : null,
332
+ jsx: codeTabs?.jsxButtonId ? document.getElementById(codeTabs.jsxButtonId) : null,
333
+ };
334
+
335
+ setCodeTab = (tab: CodeTab) => {
336
+ document.documentElement.dataset.editortsCodeTab = tab;
337
+
338
+ const containers: Record<CodeTab, HTMLElement | null> = {
339
+ files: filesViewerContainer,
340
+ viewer: viewerEditorContainer,
341
+ js: jsEditorContainer,
342
+ css: cssEditorContainer,
343
+ json: jsonEditorContainer,
344
+ jsx: jsxEditorContainer,
225
345
  };
226
- toolbar.appendChild(btn);
227
- });
228
346
 
229
- el.appendChild(toolbar);
230
- }
347
+ (Object.keys(containers) as CodeTab[]).forEach((key) => {
348
+ const el = containers[key];
349
+ if (!el) return;
350
+ el.style.display = key === tab ? '' : 'none';
351
+ });
352
+
353
+ (Object.keys(codeTabButtons) as CodeTab[]).forEach((key) => {
354
+ const btn = codeTabButtons[key];
355
+ if (!btn) return;
356
+ btn.classList.toggle('active', key === tab);
357
+ btn.setAttribute('aria-pressed', String(key === tab));
358
+ });
359
+
360
+ onCodeTabChange?.(tab);
361
+
362
+ };
363
+
364
+ const originalDisplayByEl = new Map<HTMLElement, string>();
365
+ codeViewContainers.forEach((el) => originalDisplayByEl.set(el, el.style.display));
366
+ const originalIframeDisplay = iframe.style.display;
367
+
368
+ const editorButton = viewTabs.editorButtonId
369
+ ? (document.getElementById(viewTabs.editorButtonId) as HTMLButtonElement | null)
370
+ : null;
371
+
372
+ const codeButton = viewTabs.codeButtonId
373
+ ? (document.getElementById(viewTabs.codeButtonId) as HTMLButtonElement | null)
374
+ : null;
375
+
376
+ setView = (view: 'editor' | 'code') => {
377
+ document.documentElement.dataset.editortsView = view;
378
+ document.documentElement.setAttribute('data-editorts-view', view);
379
+
380
+ editorButton?.classList.toggle('active', view === 'editor');
381
+ editorButton?.setAttribute('aria-pressed', String(view === 'editor'));
382
+ codeButton?.classList.toggle('active', view === 'code');
383
+ codeButton?.setAttribute('aria-pressed', String(view === 'code'));
384
+
385
+ if (view === 'code') {
386
+ iframe.style.display = 'none';
387
+
388
+ // Show the code containers, then if code tabs are enabled,
389
+ // immediately apply the active tab to hide the others.
390
+ codeViewContainers.forEach((el) => {
391
+ const original = originalDisplayByEl.get(el);
392
+ el.style.display = original ?? '';
393
+ });
394
+
395
+ if (codeTabs) {
396
+ const active = (document.documentElement.dataset.editortsCodeTab as CodeTab | undefined) ?? (codeTabs.defaultTab ?? 'js');
397
+ setCodeTab?.(active as CodeTab);
398
+ }
399
+ } else {
400
+ iframe.style.display = originalIframeDisplay ?? '';
401
+ codeViewContainers.forEach((el) => {
402
+ el.style.display = 'none';
403
+ });
404
+ }
405
+ };
231
406
 
232
- function handleToolbarAction(action, elementId) {
233
- const el = document.getElementById(elementId);
234
- if (!el) return;
407
+ if (viewTabs.editorButtonId) {
408
+ if (editorButton) {
409
+ editorButton.addEventListener('click', () => setView?.('editor'));
410
+ } else {
411
+ console.warn(`EditorTs: editorButtonId element #${viewTabs.editorButtonId} not found`);
412
+ }
413
+ }
235
414
 
236
- if (action === 'delete') {
237
- el.remove();
415
+ if (viewTabs.codeButtonId) {
416
+ if (codeButton) {
417
+ codeButton.addEventListener('click', () => setView?.('code'));
418
+ } else {
419
+ console.warn(`EditorTs: codeButtonId element #${viewTabs.codeButtonId} not found`);
420
+ }
238
421
  }
239
- }
240
422
 
241
- // Initialize
242
- if (document.readyState === 'loading') {
243
- document.addEventListener('DOMContentLoaded', initWYSIWYG);
244
- } else {
245
- initWYSIWYG();
423
+ // Default to the canvas unless configured otherwise.
424
+ setView(viewTabs.defaultView ?? 'editor');
425
+
426
+ // Ensure the attribute exists even when viewTabs wiring is disabled.
427
+ document.documentElement.setAttribute('data-editorts-view', viewTabs.defaultView ?? 'editor');
428
+
429
+ if (codeTabs) {
430
+ (Object.entries(codeTabButtons) as Array<[keyof typeof codeTabButtons, HTMLElement | null]>).forEach(([tab, btn]) => {
431
+ btn?.addEventListener('click', () => setCodeTab?.(tab));
432
+ });
433
+
434
+ setCodeTab?.(codeTabs.defaultTab ?? 'js');
435
+ }
246
436
  }
247
- </script>
248
- </html>`;
249
437
 
250
- // Load content into iframe
251
- iframe.srcdoc = iframeContent;
438
+ // Initialize component palette if container provided
439
+ let componentPalette: ComponentPalette | null = null;
440
+ let pendingInsertType: string | null = null;
252
441
 
253
- // Handle messages from iframe
254
- window.addEventListener('message', (event) => {
255
- if (event.data.type === 'editorts:componentSelected') {
256
- const component = page.components.findById(event.data.id);
257
- if (component) {
258
- // Update selected info container if provided
259
- if (selectedInfoContainer && config.ui?.selectedInfo?.enabled !== false) {
260
- selectedInfoContainer.innerHTML = `
261
- <div><strong>ID:</strong> ${event.data.id}</div>
262
- <div><strong>Tag:</strong> ${event.data.tagName}</div>
263
- `;
442
+ if (componentPaletteContainer && config.ui?.componentPalette?.enabled !== false) {
443
+ componentPalette = new ComponentPalette({
444
+ container: componentPaletteContainer,
445
+ registry: componentRegistry,
446
+ onPick: (type) => {
447
+ pendingInsertType = type;
448
+ componentPalette?.setSelected(type);
449
+
450
+ iframe.contentWindow?.postMessage(
451
+ {
452
+ type: 'editorts:placementMode',
453
+ enabled: true,
454
+ },
455
+ '*'
456
+ );
457
+ },
458
+ });
459
+ }
460
+
461
+ // Initialize layer manager if container provided
462
+ let layerManager: LayerManager | null = null;
463
+ if (layersContainer && config.ui?.layers?.enabled !== false) {
464
+ layerManager = new LayerManager({
465
+ container: layersContainer,
466
+ onSelect: (component) => {
467
+ // Notify iframe to select this component
468
+ const id = component.attributes?.id;
469
+ if (id) {
470
+ iframe.contentWindow?.postMessage({
471
+ type: 'editorts:selectComponent',
472
+ id: id
473
+ }, '*');
264
474
  }
265
475
 
266
476
  // Emit event
@@ -268,90 +478,2683 @@ ${page.getHTML()}
268
478
  if (config.onComponentSelect) {
269
479
  config.onComponentSelect(component);
270
480
  }
481
+ },
482
+ onReorder: (componentId, newParentId, newIndex) => {
483
+ // Reorder in component manager
484
+ page.components.moveComponent(componentId, newParentId, newIndex);
485
+
486
+ // Emit event
487
+ const component = page.components.findById(componentId);
488
+ if (component) {
489
+ emit('componentReorder', component, newParentId, newIndex);
490
+ }
491
+
492
+ void commitSnapshot({ source: 'user', message: 'reorder component' });
493
+
494
+ // Refresh iframe
495
+ refresh();
271
496
  }
272
- } else if (event.data.type === 'editorts:getToolbar') {
273
- // Send toolbar config to iframe
274
- const component = page.components.findById(event.data.id);
275
- if (component) {
276
- const toolbarConfig = page.toolbars.getToolbarForComponent(component);
277
- iframe.contentWindow?.postMessage({
278
- type: 'editorts:toolbarConfig',
279
- config: toolbarConfig,
280
- elementId: event.data.id
281
- }, '*');
282
- }
283
- } else if (event.data.type === 'editorts:toolbarAction') {
284
- handleToolbarAction(event.data.action, event.data.elementId);
497
+ });
498
+
499
+ // Initial render
500
+ layerManager.update(page.components.getAll());
501
+ }
502
+
503
+ const renderStats = () => {
504
+ if (!statsContainer || config.ui?.stats?.enabled === false) return;
505
+
506
+ statsContainer.innerHTML = `
507
+ <div style="font-size: 0.85rem;">
508
+ <div>Components: ${page.components.count()}</div>
509
+ <div>Styles: ${page.styles.count()}</div>
510
+ <div>Assets: ${page.assets.count()}</div>
511
+ </div>
512
+ `;
513
+ };
514
+
515
+ // Populate stats if container provided
516
+ renderStats();
517
+
518
+ // Populate multipage dropdown (if enabled)
519
+ // Actual render function is defined later; we call it from refresh().
520
+
521
+ const setAiChatExpanded = (expanded: boolean) => {
522
+ if (!shouldEnableAiChatUi) return;
523
+
524
+ const root = aiChatRoot ?? aiChatExpandButton?.closest('[data-editorts-ai-chat-root]') as HTMLElement | null;
525
+ if (!root) return;
526
+
527
+ root.dataset.editortsAiChatExpanded = expanded ? 'true' : 'false';
528
+
529
+ const expandedClassName = aiChatConfig?.expandedClassName ?? 'editorts-ai-chat-expanded';
530
+ const collapsedClassName = aiChatConfig?.collapsedClassName ?? 'editorts-ai-chat-collapsed';
531
+
532
+ root.classList.toggle(expandedClassName, expanded);
533
+ root.classList.toggle(collapsedClassName, !expanded);
534
+
535
+ if (aiChatExpandButton) {
536
+ aiChatExpandButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
285
537
  }
286
- });
538
+ };
287
539
 
288
- // Handle toolbar actions
289
- function handleToolbarAction(actionId: string, elementId: string) {
290
- const component = page.components.findById(elementId);
291
- if (!component) return;
540
+ if (shouldEnableAiChatUi && aiChatExpandButton) {
541
+ const initial = aiChatConfig?.defaultExpanded === true;
542
+ setAiChatExpanded(initial);
292
543
 
293
- switch (actionId) {
294
- case 'edit':
295
- emit('componentEdit', component);
296
- if (config.onComponentEdit) {
297
- config.onComponentEdit(component);
298
- }
299
- break;
544
+ aiChatExpandButton.addEventListener('click', () => {
545
+ const root = aiChatRoot ?? (aiChatExpandButton.closest('[data-editorts-ai-chat-root]') as HTMLElement | null);
546
+ const current = root?.dataset.editortsAiChatExpanded === 'true';
547
+ setAiChatExpanded(!current);
548
+ });
549
+ }
300
550
 
301
- case 'editJS':
302
- emit('componentEditJS', component);
303
- break;
551
+ // Initialize storage manager early so AI UI helpers can access it.
552
+ const storage = new StorageManager(config.storage);
304
553
 
305
- case 'duplicate':
306
- const clone = JSON.parse(JSON.stringify(component));
307
- clone.attributes = clone.attributes || {};
308
- clone.attributes.id = elementId + '-copy-' + Date.now();
309
- page.components.addComponent(clone);
310
-
311
- emit('componentDuplicate', component, clone);
312
- if (config.onComponentDuplicate) {
313
- config.onComponentDuplicate(component, clone);
554
+ // Command palette state
555
+ const commandPaletteConfig = config.ui?.commandPalette;
556
+ const commandPaletteEnabled = commandPaletteConfig?.enabled !== false
557
+ && !!commandPaletteContainer
558
+ && !!commandPaletteInput
559
+ && !!commandPaletteResults;
560
+
561
+ let isCommandPaletteOpen = false;
562
+ type CommandPaletteEntry = {
563
+ kind: 'component' | 'command';
564
+ type?: string;
565
+ label: string;
566
+ action: () => void | Promise<void>;
567
+ };
568
+
569
+ let commandPaletteEntries: CommandPaletteEntry[] = [];
570
+ let commandPaletteActiveIndex = 0;
571
+ let isRenderingCommandPalette = false;
572
+ let renderCommandPaletteResults = (): void => { };
573
+ let openCommandPalette = (): void => { };
574
+ let closeCommandPalette = (): void => { };
575
+
576
+ // Optional AI provider module (lazy)
577
+ let ai: EditorTsAiModule | undefined;
578
+
579
+ if (config.aiProvider?.provider === 'opencode') {
580
+ const aiConfig: OpencodeAiProviderConfig = config.aiProvider;
581
+ const mode: AiProviderMode = aiConfig.mode ?? 'client';
582
+
583
+ const externalClient = aiConfig.client;
584
+ const externalServer = aiConfig.server;
585
+
586
+ let server: { url: string; close(): void } | null = null;
587
+ let clientPromise: Promise<import('@opencode-ai/sdk').OpencodeClient> | null = null;
588
+
589
+ const loadSdk = async (): Promise<typeof import('@opencode-ai/sdk')> => {
590
+ return import('@opencode-ai/sdk');
591
+ };
592
+
593
+ const aiSessionStorageKey = 'ai_sessions';
594
+ const aiSessionCurrentKey = 'ai_session_current';
595
+
596
+ let currentSessionId: string | null = null;
597
+
598
+ const loadSessionIndex = async (): Promise<{ current: string | null; sessions: Array<{ id: string; title?: string }> }> => {
599
+ const rawSessions = await storage.loadPage(aiSessionStorageKey);
600
+ const rawCurrent = await storage.loadPage(aiSessionCurrentKey);
601
+
602
+ let sessions: Array<{ id: string; title?: string }> = [];
603
+ if (rawSessions) {
604
+ try {
605
+ const parsed = JSON.parse(rawSessions) as unknown;
606
+ if (Array.isArray(parsed)) {
607
+ sessions = parsed
608
+ .filter((s): s is { id: string; title?: string } => typeof (s as { id?: unknown }).id === 'string')
609
+ .map((s) => ({ id: s.id, title: typeof s.title === 'string' ? s.title : undefined }));
610
+ }
611
+ } catch {
612
+ // ignore
314
613
  }
315
-
316
- refresh();
317
- break;
614
+ }
318
615
 
319
- case 'delete':
320
- page.components.removeComponent(elementId);
321
-
322
- emit('componentDelete', component);
323
- if (config.onComponentDelete) {
324
- config.onComponentDelete(component);
616
+ let current: string | null = null;
617
+ if (rawCurrent) {
618
+ try {
619
+ const parsed = JSON.parse(rawCurrent) as unknown;
620
+ current = typeof parsed === 'string' && parsed.length > 0 ? parsed : null;
621
+ } catch {
622
+ // ignore
325
623
  }
326
-
327
- // Notify iframe to remove element
328
- iframe.contentWindow?.postMessage({
329
- type: 'editorts:toolbarAction',
330
- action: 'delete',
331
- elementId: elementId
332
- }, '*');
333
- break;
334
- }
335
- }
624
+ }
336
625
 
337
- // Refresh iframe
338
- function refresh() {
339
- iframe.srcdoc = iframeContent;
340
- }
626
+ return { current, sessions };
627
+ };
341
628
 
342
- // Save page data
343
- function save(): string {
344
- return page.toJSON();
345
- }
629
+ const saveSessionIndex = async (next: { current: string | null; sessions: Array<{ id: string; title?: string }> }) => {
630
+ await storage.savePage(aiSessionStorageKey, JSON.stringify(next.sessions, null, 2));
631
+ await storage.savePage(aiSessionCurrentKey, JSON.stringify(next.current));
632
+ };
346
633
 
347
- // Destroy editor
348
- function destroy() {
349
- iframe.srcdoc = '';
350
- if (sidebarContainer) sidebarContainer.innerHTML = '';
634
+ const appendAiChatLog = (label: string, text: string) => {
635
+ if (!aiChatLog) return;
636
+ aiChatLog.textContent = `${aiChatLog.textContent ?? ''}${label}: ${text}\n\n`;
637
+ };
638
+
639
+ const appendAiChatStreamDelta = (delta: string) => {
640
+ if (!aiChatLog) return;
641
+ aiChatLog.textContent = `${aiChatLog.textContent ?? ''}${delta}`;
642
+ };
643
+
644
+ const refreshAiSessionSelect = async () => {
645
+ if (!aiSessionSelect || !ai) return;
646
+
647
+ if (currentSessionId === null) {
648
+ const index = await loadSessionIndex();
649
+ currentSessionId = index.current;
650
+ }
651
+
652
+ const sessions = await ai.sessions.list();
653
+ const current = ai.sessions.current();
654
+
655
+ aiSessionSelect.innerHTML = '';
656
+
657
+ const addOption = (id: string, label: string) => {
658
+ const opt = document.createElement('option');
659
+ opt.value = id;
660
+ opt.textContent = label;
661
+ if (id === current) {
662
+ opt.selected = true;
663
+ }
664
+ aiSessionSelect.appendChild(opt);
665
+ };
666
+
667
+ addOption('', '(auto)');
668
+
669
+ sessions.forEach((s) => {
670
+ addOption(s.id, s.title ? `${s.title} (${s.id})` : s.id);
671
+ });
672
+ };
673
+
674
+ const refreshAiModelSelect = async () => {
675
+ if (!aiModelSelect || !ai) return;
676
+
677
+ const models = await ai.models.list();
678
+ aiModelSelect.innerHTML = '';
679
+
680
+ const addModelOption = (modelID: string, label: string) => {
681
+ const opt = document.createElement('option');
682
+ opt.value = modelID;
683
+ opt.textContent = label;
684
+ aiModelSelect.appendChild(opt);
685
+ };
686
+
687
+ models.forEach((model) => {
688
+ addModelOption(model.modelID, model.modelID);
689
+ });
690
+
691
+ if (models.length === 0) {
692
+ addModelOption('', 'No models');
693
+ }
694
+ };
695
+
696
+ let lastAiReplacements: Array<{ path: string; content: string }> | null = null;
697
+
698
+ ai = {
699
+ provider: 'opencode',
700
+ mode,
701
+ getClient: async () => {
702
+ if (!clientPromise) {
703
+ if (externalClient) {
704
+ clientPromise = Promise.resolve(externalClient);
705
+ } else {
706
+ clientPromise = loadSdk().then(async (sdk) => {
707
+ if (mode === 'client') {
708
+ const baseUrl = aiBaseUrlInput?.value || aiConfig.baseUrl || aiProxiedBaseUrl;
709
+ if (!baseUrl) {
710
+ throw new Error("EditorTs: aiProvider.baseUrl is required when mode is 'client'");
711
+ }
712
+ if (typeof sdk.createOpencodeClient !== 'function') {
713
+ throw new Error('EditorTs: @opencode-ai/sdk missing createOpencodeClient');
714
+ }
715
+
716
+ const createAuthedFetch = (username: string, password: string): ((request: Request) => Promise<Response>) => {
717
+ const basic = btoa(`${username}:${password}`);
718
+
719
+ return async (request: Request): Promise<Response> => {
720
+ const headers = new Headers(request.headers);
721
+ headers.set('Authorization', `Basic ${basic}`);
722
+ const nextRequest = new Request(request, { headers });
723
+ return fetch(nextRequest);
724
+ };
725
+ };
726
+
727
+ // If the app is using a same-origin proxy (like /opencode/*), do NOT send basic auth
728
+ // from the browser; let the proxy attach it.
729
+ const auth = baseUrl.startsWith(window.location.origin)
730
+ ? undefined
731
+ : aiConfig.auth;
732
+
733
+ return sdk.createOpencodeClient({
734
+ baseUrl,
735
+ fetch: auth ? createAuthedFetch(auth.username ?? 'opencode', auth.password) : undefined,
736
+ });
737
+ }
738
+
739
+ if (typeof sdk.createOpencode !== 'function') {
740
+ throw new Error('EditorTs: @opencode-ai/sdk missing createOpencode');
741
+ }
742
+
743
+ const opencode = await sdk.createOpencode({
744
+ hostname: aiConfig.hostname,
745
+ port: aiConfig.port,
746
+ signal: aiConfig.signal,
747
+ timeout: aiConfig.timeout,
748
+ config: aiConfig.config ?? {},
749
+ });
750
+
751
+ server = opencode.server;
752
+ return opencode.client;
753
+ });
754
+ }
755
+ }
756
+
757
+ return clientPromise;
758
+ },
759
+ getUrl: () => {
760
+ if (mode === 'client') return aiConfig.baseUrl ?? externalServer?.url ?? null;
761
+ return server?.url ?? externalServer?.url ?? null;
762
+ },
763
+ sessions: {
764
+ current: () => {
765
+ return currentSessionId;
766
+ },
767
+ setCurrent: async (sessionId: string | null) => {
768
+ currentSessionId = sessionId;
769
+ const index = await loadSessionIndex();
770
+ await saveSessionIndex({ ...index, current: sessionId });
771
+ },
772
+ list: async () => {
773
+ const index = await loadSessionIndex();
774
+ return index.sessions;
775
+ },
776
+ create: async (title?: string) => {
777
+ const client = await ai!.getClient();
778
+ const result = await client.session.create({ body: { title: title ?? 'EditorTs Chat' } });
779
+ if (!result.data) {
780
+ throw new Error(`Failed to create session: ${String(result.error)}`);
781
+ }
782
+
783
+ const created = { id: result.data.id, title: result.data.title };
784
+
785
+ const index = await loadSessionIndex();
786
+ const nextSessions = [created, ...index.sessions.filter((s) => s.id !== created.id)].slice(0, 50);
787
+ await saveSessionIndex({ current: created.id, sessions: nextSessions });
788
+
789
+ return created;
790
+ },
791
+ },
792
+ models: {
793
+ list: async () => {
794
+ const result: Array<{ providerID: string; modelID: string }> = [];
795
+
796
+ const addModel = (providerID: string, modelID: string) => {
797
+ const normalized = normalizeOpencodeModelId(providerID, modelID);
798
+ if (!result.some((entry) => entry.providerID === providerID && entry.modelID === normalized)) {
799
+ result.push({ providerID, modelID: normalized });
800
+ }
801
+ };
802
+
803
+ addModel('opencode', 'gemini-3-pro');
804
+ addModel('opencode', 'claude-sonnet-4-5');
805
+ addModel('opencode', 'gpt-5.2-codex');
806
+
807
+ try {
808
+ const client = await ai!.getClient();
809
+ const providers = await client.config.providers();
810
+ const defaultModel = providers.data?.default?.opencode;
811
+ if (defaultModel) {
812
+ addModel('opencode', defaultModel);
813
+ }
814
+ } catch {
815
+ // ignore provider lookup failures
816
+ }
817
+
818
+ return result;
819
+ },
820
+ },
821
+
822
+ chat: async (
823
+ prompt: string,
824
+ options?: {
825
+ sessionId?: string;
826
+ model?: {
827
+ providerID: string;
828
+ modelID: string;
829
+ };
830
+ stream?: boolean;
831
+ onStream?: (delta: string) => void;
832
+ }
833
+ ) => {
834
+ const client = await ai!.getClient();
835
+
836
+ const componentScripts: Record<string, string> = {};
837
+ const collectScripts = (components: Component[]) => {
838
+ components.forEach((component) => {
839
+ const id = typeof component.attributes?.id === 'string' ? component.attributes.id : null;
840
+ if (id) {
841
+ componentScripts[`components/${id}.js`] = typeof component.script === 'string' ? component.script : '';
842
+ }
843
+ if (component.components && component.components.length > 0) {
844
+ collectScripts(component.components);
845
+ }
846
+ });
847
+ };
848
+ collectScripts(page.components.getAll());
849
+
850
+ if (currentSessionId === null) {
851
+ const index = await loadSessionIndex();
852
+ currentSessionId = index.current;
853
+ }
854
+
855
+ const sessionId = options?.sessionId ?? currentSessionId;
856
+
857
+ const shouldStream = options?.stream ?? aiConfig.stream?.enabled === true;
858
+ const selectedModel = options?.model;
859
+
860
+ const result = await requestAiReplacements({
861
+ client,
862
+ prompt,
863
+ pageJson: save(),
864
+ css: page.getCSS() ?? '',
865
+ componentScripts,
866
+ sessionId: sessionId ?? undefined,
867
+ model: selectedModel,
868
+ stream: shouldStream,
869
+ onStream: options?.onStream,
870
+ });
871
+
872
+ // Persist session for reuse.
873
+ if (result.sessionId) {
874
+ currentSessionId = result.sessionId;
875
+ const index = await loadSessionIndex();
876
+ const nextSessions = [{ id: result.sessionId }, ...index.sessions.filter((s) => s.id !== result.sessionId)].slice(0, 50);
877
+ await saveSessionIndex({ current: result.sessionId, sessions: nextSessions });
878
+ }
879
+
880
+ return result;
881
+ },
882
+ apply: async (replacements) => {
883
+ // Apply potentially many files, then refresh once.
884
+ await applyAiReplacementsToPage({
885
+ page,
886
+ replacements,
887
+ saveJson: async (jsonText: string) => {
888
+ const toolbarRuntimeConfig = page.toolbars.exportConfig();
889
+ const parsed = JSON.parse(jsonText) as PageData | MultiPageData;
890
+
891
+ if (isMultiPageData(parsed)) {
892
+ if (!parsed.pages || parsed.pages.length === 0) {
893
+ throw new Error('MultiPageData.pages cannot be empty');
894
+ }
895
+
896
+ multiPageData = parsed;
897
+ activePageIndex = parsed.activePageIndex ?? 0;
898
+
899
+ const loadedPageData = resolvePageData(parsed.pages[activePageIndex] ?? parsed.pages[0]!);
900
+ const newPage = new Page(loadedPageData);
901
+ Object.assign(page, newPage);
902
+ } else {
903
+ multiPageData = null;
904
+ activePageIndex = 0;
905
+
906
+ const newPage = new Page(resolvePageData(parsed as PageData));
907
+ Object.assign(page, newPage);
908
+ }
909
+
910
+ page.toolbars.importConfig(toolbarRuntimeConfig);
911
+
912
+ if (workspace) {
913
+ await workspace.fs.writeFile('page.json', jsonText, { isModelContentChange: true });
914
+ }
915
+ },
916
+ saveCss: async (cssText: string) => {
917
+ page.styles.setCompiledCSS(cssText);
918
+ if (workspace) {
919
+ await workspace.fs.writeFile('styles.css', cssText, { isModelContentChange: true });
920
+ }
921
+ },
922
+ saveComponentScript: async (id: string, script: string) => {
923
+ page.components.updateComponent(id, { script });
924
+ if (workspace) {
925
+ await workspace.fs.writeFile(`components/${id}.js`, script, { isModelContentChange: true });
926
+ }
927
+ },
928
+ });
929
+
930
+ await commitSnapshot({ source: 'ai', message: 'apply ai changes' });
931
+
932
+ refresh();
933
+ refreshLayers();
934
+ },
935
+ close: async () => {
936
+ // Only close server that EditorTs started itself.
937
+ if (server) {
938
+ server.close();
939
+ }
940
+ server = null;
941
+ },
942
+
943
+
944
+ };
945
+
946
+ // Wire AI Chat UI controls, if the app provided them.
947
+ if (shouldEnableAiChatUi) {
948
+ const autoApply = aiChatConfig?.autoApply !== false;
949
+ const streamEnabled = aiChatConfig?.stream?.enabled ?? aiConfig.stream?.enabled === true;
950
+
951
+ if (aiChatLinkAnchor) {
952
+ const path = aiChatConfig?.link?.path ?? '/chats';
953
+
954
+ const baseUrl = ai?.getUrl() ?? aiBaseUrlInput?.value ?? aiConfig.baseUrl ?? aiProxiedBaseUrl;
955
+ const resolvedBase = baseUrl.startsWith('http')
956
+ ? baseUrl
957
+ : `${window.location.origin}${baseUrl.startsWith('/') ? '' : '/'}${baseUrl}`;
958
+
959
+ const nextUrl = new URL(path.startsWith('/') ? path : `/${path}`, resolvedBase);
960
+ aiChatLinkAnchor.href = nextUrl.toString();
961
+ aiChatLinkAnchor.target = '_blank';
962
+ aiChatLinkAnchor.rel = 'noopener noreferrer';
963
+ }
964
+
965
+ if (aiHealthButton && aiHealthStatus) {
966
+ aiHealthButton.addEventListener('click', async () => {
967
+ if (!ai) {
968
+ aiHealthStatus.textContent = 'AI provider is disabled.';
969
+ return;
970
+ }
971
+
972
+ aiHealthStatus.textContent = 'Checking...';
973
+
974
+ try {
975
+ const client = await ai.getClient();
976
+ const result = await client.config.get();
977
+ aiHealthStatus.textContent = JSON.stringify(result.data ?? result, null, 2);
978
+ } catch (err: unknown) {
979
+ aiHealthStatus.textContent = err instanceof Error ? err.message : String(err);
980
+ }
981
+ });
982
+ }
983
+
984
+ if (aiSessionSelect) {
985
+ void refreshAiSessionSelect();
986
+
987
+ aiSessionSelect.addEventListener('change', async () => {
988
+ if (!ai) return;
989
+ const next = aiSessionSelect.value.trim();
990
+ await ai.sessions.setCurrent(next.length ? next : null);
991
+ await refreshAiSessionSelect();
992
+ });
993
+ }
994
+
995
+ if (aiModelSelect) {
996
+ void refreshAiModelSelect();
997
+ }
998
+
999
+ if (aiSessionNewButton) {
1000
+ aiSessionNewButton.addEventListener('click', async () => {
1001
+ if (!ai) return;
1002
+ const created = await ai.sessions.create('EditorTs Chat');
1003
+ await ai.sessions.setCurrent(created.id);
1004
+ await refreshAiSessionSelect();
1005
+ });
1006
+ }
1007
+
1008
+ if (aiChatApplyButton) {
1009
+ aiChatApplyButton.addEventListener('click', async () => {
1010
+ if (!lastAiReplacements || lastAiReplacements.length === 0) return;
1011
+ if (!ai) {
1012
+ appendAiChatLog('error', 'AI provider is disabled.');
1013
+ return;
1014
+ }
1015
+
1016
+ try {
1017
+ await ai.apply(lastAiReplacements);
1018
+ appendAiChatLog('apply', `Applied ${lastAiReplacements.length} replacement(s).`);
1019
+ lastAiReplacements = null;
1020
+ aiChatApplyButton.toggleAttribute('disabled', true);
1021
+ } catch (err: unknown) {
1022
+ appendAiChatLog('error', err instanceof Error ? err.message : String(err));
1023
+ }
1024
+ });
1025
+ }
1026
+
1027
+ if (aiChatSendButton && aiChatInput) {
1028
+ aiChatSendButton.addEventListener('click', async () => {
1029
+ if (!ai) {
1030
+ appendAiChatLog('error', 'AI provider is disabled.');
1031
+ return;
1032
+ }
1033
+
1034
+ const prompt = aiChatInput.value.trim();
1035
+ if (!prompt) return;
1036
+
1037
+ appendAiChatLog('user', prompt);
1038
+ aiChatSendButton.toggleAttribute('disabled', true);
1039
+
1040
+ try {
1041
+ const selectedSessionId = aiSessionSelect?.value?.trim() || undefined;
1042
+
1043
+ let streamedText = '';
1044
+ if (streamEnabled && aiChatLog) {
1045
+ aiChatLog.textContent = `${aiChatLog.textContent ?? ''}assistant: `;
1046
+ }
1047
+
1048
+ const selectedModelValue = aiModelSelect?.value?.trim() || '';
1049
+ const model = selectedModelValue
1050
+ ? { providerID: 'opencode', modelID: selectedModelValue }
1051
+ : undefined;
1052
+
1053
+ const result = await ai.chat(prompt, {
1054
+ sessionId: selectedSessionId,
1055
+ model,
1056
+ stream: streamEnabled,
1057
+ onStream: streamEnabled
1058
+ ? (delta) => {
1059
+ streamedText += delta;
1060
+ appendAiChatStreamDelta(delta);
1061
+ }
1062
+ : undefined,
1063
+ });
1064
+
1065
+
1066
+ if (streamEnabled && aiChatLog) {
1067
+ aiChatLog.textContent = `${aiChatLog.textContent ?? ''}\n\n`;
1068
+ if (!streamedText.trim()) {
1069
+ appendAiChatLog('assistant', result.rawText);
1070
+ }
1071
+ } else {
1072
+ appendAiChatLog('assistant', result.rawText);
1073
+ }
1074
+
1075
+ if (result.replacements.length === 0) {
1076
+ lastAiReplacements = null;
1077
+ aiChatApplyButton?.toggleAttribute('disabled', true);
1078
+ return;
1079
+ }
1080
+
1081
+ if (!autoApply) {
1082
+ lastAiReplacements = result.replacements;
1083
+ aiChatApplyButton?.toggleAttribute('disabled', false);
1084
+ return;
1085
+ }
1086
+
1087
+ try {
1088
+ await ai.apply(result.replacements);
1089
+ appendAiChatLog('apply', `Applied ${result.replacements.length} replacement(s).`);
1090
+ lastAiReplacements = null;
1091
+ aiChatApplyButton?.toggleAttribute('disabled', true);
1092
+ } catch (err: unknown) {
1093
+ lastAiReplacements = result.replacements;
1094
+ aiChatApplyButton?.toggleAttribute('disabled', false);
1095
+ appendAiChatLog('error', err instanceof Error ? err.message : String(err));
1096
+ }
1097
+ } catch (err: unknown) {
1098
+ appendAiChatLog('error', err instanceof Error ? err.message : String(err));
1099
+ } finally {
1100
+ aiChatSendButton.toggleAttribute('disabled', false);
1101
+ }
1102
+ });
1103
+ }
1104
+ }
1105
+
1106
+ }
1107
+
1108
+ // Built-in code editor setup (optional)
1109
+ const codeEditorProvider = config.codeEditor?.provider ?? 'textarea';
1110
+
1111
+ type RuntimeCodeEditor = {
1112
+ getValue(): string;
1113
+ setValue(value: string): void;
1114
+ focus(): void;
1115
+ dispose(): void;
1116
+ };
1117
+
1118
+ type RuntimeCodeEditorOptions = {
1119
+ readOnly?: boolean;
1120
+ };
1121
+
1122
+ function createTextareaCodeEditor(
1123
+ host: HTMLElement,
1124
+ initialValue: string,
1125
+ options?: RuntimeCodeEditorOptions
1126
+ ): RuntimeCodeEditor {
1127
+ host.innerHTML = '';
1128
+
1129
+ const textarea = document.createElement('textarea');
1130
+ textarea.value = initialValue;
1131
+ textarea.spellcheck = false;
1132
+ if (options?.readOnly) {
1133
+ textarea.readOnly = true;
1134
+ }
1135
+ textarea.style.width = '100%';
1136
+ textarea.style.height = '100%';
1137
+ textarea.style.minHeight = '0';
1138
+ textarea.style.fontFamily = 'monospace';
1139
+ textarea.style.fontSize = '0.9rem';
1140
+
1141
+ host.appendChild(textarea);
1142
+
1143
+ return {
1144
+ getValue: () => textarea.value,
1145
+ setValue: (value: string) => {
1146
+ textarea.value = value;
1147
+ },
1148
+ focus: () => textarea.focus(),
1149
+ dispose: () => {
1150
+ textarea.remove();
1151
+ },
1152
+ };
1153
+ }
1154
+
1155
+ type ModernMonacoModule = typeof import('modern-monaco');
1156
+ type ModernMonaco = Awaited<ReturnType<ModernMonacoModule['init']>>;
1157
+
1158
+ let modernMonacoInitPromise: Promise<ModernMonaco> | null = null;
1159
+
1160
+ type MonacoWorkspace = import('modern-monaco').Workspace;
1161
+
1162
+ const workspaceEnabled = codeEditorProvider === 'modern-monaco' && config.codeEditor?.workspace?.enabled !== false;
1163
+ const workspaceName = config.codeEditor?.workspace?.name ?? 'editorts';
1164
+
1165
+ // Workspace variables are initialized now; code tab hooks are safe.
1166
+ codeTabHooksReady = true;
1167
+
1168
+ let workspace: MonacoWorkspace | null = null;
1169
+
1170
+ const buildWorkspaceFiles = (): Record<string, string> => {
1171
+ const files: Record<string, string> = {};
1172
+
1173
+ files['page.json'] = save();
1174
+ files['styles.css'] = page.getCSS() ?? '';
1175
+ files['index.html'] = `<!DOCTYPE html><html><head><meta charset="utf-8" /><link rel="stylesheet" href="styles.css" /></head>${page.getHTML()}</html>`;
1176
+
1177
+ // Per-component scripts
1178
+ const collect = (components: Component[]) => {
1179
+ components.forEach((component) => {
1180
+ const id = typeof component.attributes?.id === 'string' ? component.attributes.id : null;
1181
+ if (id) {
1182
+ const content = typeof component.script === 'string' ? component.script : '';
1183
+ files[`components/${id}.js`] = content;
1184
+ }
1185
+ if (component.components && component.components.length > 0) {
1186
+ collect(component.components);
1187
+ }
1188
+ });
1189
+ };
1190
+
1191
+ collect(page.components.getAll());
1192
+
1193
+ return files;
1194
+ };
1195
+
1196
+ const ensureWorkspace = async (mod: ModernMonacoModule): Promise<MonacoWorkspace | null> => {
1197
+ if (!workspaceEnabled) return null;
1198
+ if (workspace) return workspace;
1199
+
1200
+ const files = buildWorkspaceFiles();
1201
+ workspace = new mod.Workspace({
1202
+ name: workspaceName,
1203
+ initialFiles: files,
1204
+ entryFile: 'index.html',
1205
+ });
1206
+
1207
+ return workspace;
1208
+ };
1209
+
1210
+ async function syncWorkspaceFiles(): Promise<void> {
1211
+ if (!workspace) return;
1212
+
1213
+ const files = buildWorkspaceFiles();
1214
+ await Promise.all(
1215
+ Object.entries(files).map(async ([path, content]) => {
1216
+ await workspace!.fs.writeFile(path, content, { isModelContentChange: false });
1217
+ })
1218
+ );
1219
+ }
1220
+
1221
+ async function loadModernMonaco(mod: ModernMonacoModule, ws: MonacoWorkspace | null): Promise<ModernMonaco> {
1222
+ if (!modernMonacoInitPromise) {
1223
+ modernMonacoInitPromise = (async () => {
1224
+ if (typeof mod.init !== 'function') {
1225
+ throw new Error('modern-monaco missing init() export');
1226
+ }
1227
+
1228
+ // Ensure builtin LSP is enabled even if MonacoEnvironment was overwritten.
1229
+ const globalWithEnv = globalThis as unknown as { MonacoEnvironment?: Record<string, unknown> };
1230
+ globalWithEnv.MonacoEnvironment = {
1231
+ ...(globalWithEnv.MonacoEnvironment ?? {}),
1232
+ useBuiltinLSP: true,
1233
+ };
1234
+
1235
+ return mod.init({
1236
+ workspace: ws ?? undefined,
1237
+ // Enable built-in language services (JS/TS/CSS/JSON/HTML).
1238
+ lsp: {
1239
+ typescript: {},
1240
+ css: {},
1241
+ json: {
1242
+ // Provide a minimal schema so page.json completions are obvious.
1243
+ schemas: [
1244
+ {
1245
+ uri: 'https://editorts.dev/schemas/page.json',
1246
+ fileMatch: ['page.json'],
1247
+ schema: {
1248
+ type: 'object',
1249
+ properties: {
1250
+ title: { type: 'string' },
1251
+ item_id: { type: 'number' },
1252
+ body: { type: 'object' },
1253
+ },
1254
+ },
1255
+ },
1256
+ ],
1257
+ },
1258
+ html: {},
1259
+ },
1260
+ });
1261
+ })();
1262
+ }
1263
+
1264
+ return modernMonacoInitPromise;
1265
+ }
1266
+
1267
+ async function createModernMonacoCodeEditor(
1268
+ host: HTMLElement,
1269
+ initialValue: string,
1270
+ language: 'javascript' | 'typescript' | 'css' | 'json',
1271
+ options?: RuntimeCodeEditorOptions
1272
+ ): Promise<RuntimeCodeEditor> {
1273
+ host.innerHTML = '';
1274
+
1275
+ const monacoHost = document.createElement('div');
1276
+ monacoHost.style.width = '100%';
1277
+ monacoHost.style.height = '100%';
1278
+ monacoHost.style.minHeight = '0';
1279
+ host.appendChild(monacoHost);
1280
+
1281
+ const mod = await import('modern-monaco');
1282
+ const ws = await ensureWorkspace(mod);
1283
+
1284
+ const monaco = await loadModernMonaco(mod, ws);
1285
+
1286
+ const editor = monaco.editor.create(monacoHost, {
1287
+ automaticLayout: true,
1288
+ minimap: { enabled: false },
1289
+ readOnly: options?.readOnly === true,
1290
+ });
1291
+
1292
+ const openFile = async (path: string, fallback: string): Promise<ReturnType<typeof monaco.editor.createModel>> => {
1293
+ if (ws) {
1294
+ await ws.fs.writeFile(path, fallback, { isModelContentChange: false });
1295
+
1296
+ // modern-monaco's public `openTextDocument()` opens in the first editor.
1297
+ // We need to open into *this* editor instance.
1298
+ const internal = ws as unknown as {
1299
+ _openTextDocument?: (
1300
+ uri: string,
1301
+ editor: ReturnType<typeof monaco.editor.create>,
1302
+ selectionOrPosition?: unknown,
1303
+ readonlyContent?: string
1304
+ ) => Promise<ReturnType<typeof monaco.editor.createModel>>;
1305
+ };
1306
+
1307
+ const opened = typeof internal._openTextDocument === 'function'
1308
+ ? await internal._openTextDocument(path, editor)
1309
+ : await ws.openTextDocument(path);
1310
+
1311
+ const languageId = language === 'typescript' ? 'typescript' : language;
1312
+ monaco.editor.setModelLanguage(opened, languageId);
1313
+
1314
+ return opened;
1315
+ }
1316
+
1317
+ const extByLanguage: Record<string, string> = {
1318
+ javascript: 'js',
1319
+ typescript: 'tsx',
1320
+ css: 'css',
1321
+ json: 'json',
1322
+ };
1323
+
1324
+ const ext = extByLanguage[language] ?? 'txt';
1325
+
1326
+ const uri = monaco?.Uri?.parse
1327
+ ? monaco.Uri.parse(`file:///editorts/${language}/${Date.now()}.${ext}`)
1328
+ : undefined;
1329
+
1330
+ const model = monaco.editor.createModel(fallback ?? '', language === 'typescript' ? 'typescript' : language, uri);
1331
+ return model;
1332
+ };
1333
+
1334
+ // Default file mapping per language
1335
+ const defaultPathByLanguage: Record<typeof language, string> = {
1336
+ javascript: 'components/selected.js',
1337
+ typescript: 'export.tsx',
1338
+ css: 'styles.css',
1339
+ json: 'page.json',
1340
+ };
1341
+
1342
+ const initialPath = defaultPathByLanguage[language];
1343
+ const model = await openFile(initialPath, initialValue ?? '');
1344
+
1345
+ editor.setModel(model);
1346
+
1347
+ return {
1348
+ getValue: () => model.getValue(),
1349
+ setValue: (value: string) => model.setValue(value ?? ''),
1350
+ focus: () => editor.focus(),
1351
+ dispose: () => {
1352
+ editor.dispose();
1353
+ if (!ws) {
1354
+ model.dispose();
1355
+ }
1356
+ monacoHost.remove();
1357
+ },
1358
+ };
1359
+
1360
+ }
1361
+
1362
+ // Built-in code editor setup (optional)
1363
+
1364
+ async function createCodeEditor(
1365
+ host: HTMLElement,
1366
+ initialValue: string,
1367
+ language: 'javascript' | 'typescript' | 'css' | 'json',
1368
+ options?: RuntimeCodeEditorOptions
1369
+ ): Promise<RuntimeCodeEditor> {
1370
+ if (codeEditorProvider === 'modern-monaco') {
1371
+ try {
1372
+ return await createModernMonacoCodeEditor(host, initialValue, language, options);
1373
+ } catch (err: unknown) {
1374
+ modernMonacoInitPromise = null;
1375
+ const message = err instanceof Error ? err.message : String(err);
1376
+ console.warn('Failed to load modern-monaco; falling back to textarea:', message);
1377
+ return createTextareaCodeEditor(host, initialValue, options);
1378
+ }
1379
+ }
1380
+
1381
+ return createTextareaCodeEditor(host, initialValue, options);
1382
+ }
1383
+
1384
+ // Code editor instances
1385
+ let jsEditor: RuntimeCodeEditor | null = null;
1386
+ let cssEditor: RuntimeCodeEditor | null = null;
1387
+ let jsonEditor: RuntimeCodeEditor | null = null;
1388
+
1389
+ // Track selected component for JS editor
1390
+ let selectedComponentId: string | null = null;
1391
+
1392
+ // Build iframe content with WYSIWYG
1393
+ // NOTE: this must be built on-demand so refresh() reflects current Page state.
1394
+ const buildIframeContent = () => buildIframeCanvasSrcdocFromPage(page);
1395
+
1396
+
1397
+ // Load content into iframe
1398
+ iframe.srcdoc = buildIframeContent();
1399
+
1400
+ // multipage dropdown is rendered after helpers are defined
1401
+
1402
+ // --- Optional code editors (JS/CSS/JSON) ---
1403
+ const shouldEnableFilesViewer = !!filesViewerContainer && config.ui?.editors?.files?.enabled !== false;
1404
+ const shouldEnableViewer = !!viewerEditorContainer && config.ui?.editors?.viewer?.enabled !== false;
1405
+ const shouldEnableJsEditor = !!jsEditorContainer && config.ui?.editors?.js?.enabled !== false;
1406
+ const shouldEnableCssEditor = !!cssEditorContainer && config.ui?.editors?.css?.enabled !== false;
1407
+ const shouldEnableJsonEditor = !!jsonEditorContainer && config.ui?.editors?.json?.enabled !== false;
1408
+ const shouldEnableJsxEditor = !!jsxEditorContainer && config.ui?.editors?.jsx?.enabled !== false;
1409
+
1410
+ // Render editor panels
1411
+ if (shouldEnableFilesViewer && filesViewerContainer) {
1412
+ filesViewerContainer.innerHTML = `
1413
+ <div style="display:flex; flex-direction:column; gap:0.5rem; height:100%;">
1414
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; flex:0 0 auto;">
1415
+ <strong>Workspace Files</strong>
1416
+ <button data-editorts-action="refresh-files" type="button">Refresh</button>
1417
+ </div>
1418
+ <input
1419
+ data-editorts-field="files-filter"
1420
+ type="text"
1421
+ placeholder="Filter files…"
1422
+ style="width:100%; padding:0.4rem 0.5rem; border:1px solid rgba(0,0,0,0.12); border-radius:6px;"
1423
+ />
1424
+ <div data-editorts-field="files-list" style="flex:1 1 auto; min-height:0; overflow:auto; border:1px solid rgba(0,0,0,0.08); border-radius:6px; padding:0.5rem;"></div>
1425
+ </div>
1426
+ `;
1427
+ }
1428
+
1429
+ if (shouldEnableViewer && viewerEditorContainer) {
1430
+ viewerEditorContainer.innerHTML = `
1431
+ <div style="display:flex; flex-direction:column; gap:0.5rem; height:100%;">
1432
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; flex:0 0 auto;">
1433
+ <div style="display:flex; flex-direction:column; gap:0.125rem;">
1434
+ <strong>Preview</strong>
1435
+ <div data-editorts-field="viewer-path" style="font-size:0.85rem; opacity:0.8;"></div>
1436
+ </div>
1437
+ </div>
1438
+ <div data-editorts-field="viewer-editor" style="flex:1 1 auto; min-height:0;"></div>
1439
+ </div>
1440
+ `;
1441
+ }
1442
+
1443
+ if (shouldEnableJsEditor && jsEditorContainer) {
1444
+ jsEditorContainer.innerHTML = `
1445
+ <div style="display:flex; flex-direction:column; gap:0.5rem; height:100%;">
1446
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; flex:0 0 auto;">
1447
+ <strong>Component JavaScript</strong>
1448
+ <button data-editorts-action="save-js" type="button">Save</button>
1449
+ </div>
1450
+ <div data-editorts-field="js-status" style="font-size:0.85rem; opacity:0.8; flex:0 0 auto;">Select a component to edit its script</div>
1451
+ <div style="display:flex; gap:0.75rem; align-items:stretch; flex:1 1 auto; min-height:0;">
1452
+ <div data-editorts-field="js-files" style="width:12rem; border:1px solid rgba(0,0,0,0.1); border-radius:6px; padding:0.5rem; overflow:auto; min-height:0;"></div>
1453
+ <div style="flex:1; min-width:0; min-height:0;" data-editorts-field="js-editor"></div>
1454
+ </div>
1455
+ </div>
1456
+ `;
1457
+ }
1458
+
1459
+ if (shouldEnableCssEditor && cssEditorContainer) {
1460
+ cssEditorContainer.innerHTML = `
1461
+ <div style="display:flex; flex-direction:column; gap:0.5rem; height:100%;">
1462
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; flex:0 0 auto;">
1463
+ <strong>Page CSS</strong>
1464
+ <button data-editorts-action="save-css" type="button">Save</button>
1465
+ </div>
1466
+ <div data-editorts-field="css-editor" style="flex:1 1 auto; min-height:0;"></div>
1467
+ </div>
1468
+ `;
1469
+ }
1470
+
1471
+ if (shouldEnableJsonEditor && jsonEditorContainer) {
1472
+ jsonEditorContainer.innerHTML = `
1473
+ <div style="display:flex; flex-direction:column; gap:0.5rem; height:100%;">
1474
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; flex:0 0 auto;">
1475
+ <div style="font-weight:600;">Page JSON</div>
1476
+ <div style="display:flex; gap:0.5rem;">
1477
+ <button data-editorts-action="save-json">Apply</button>
1478
+ </div>
1479
+ </div>
1480
+ <div data-editorts-field="json-editor" style="flex:1 1 auto; min-height:0;"></div>
1481
+ <div data-editorts-field="json-error" style="color:#ef4444; display:none; flex:0 0 auto;"></div>
1482
+ </div>
1483
+ `;
1484
+ }
1485
+
1486
+ if (shouldEnableJsxEditor && jsxEditorContainer) {
1487
+ jsxEditorContainer.innerHTML = `
1488
+ <div style="display:flex; flex-direction:column; gap:0.5rem; height:100%;">
1489
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:0.5rem; flex:0 0 auto;">
1490
+ <div style="font-weight:600;">JSX</div>
1491
+ <div style="display:flex; gap:0.5rem;">
1492
+ <button data-editorts-action="export-jsx">Export</button>
1493
+ </div>
1494
+ </div>
1495
+ <div data-editorts-field="jsx-editor" style="flex:1 1 auto; min-height:0;"></div>
1496
+ <div data-editorts-field="jsx-error" style="color:#ef4444; display:none; flex:0 0 auto;"></div>
1497
+ </div>
1498
+ `;
1499
+ }
1500
+
1501
+
1502
+ const listWorkspaceFiles = async (): Promise<string[]> => {
1503
+ if (!workspace) return [];
1504
+
1505
+ const out: string[] = [];
1506
+
1507
+ const walk = async (dir: string) => {
1508
+ const entries = await workspace!.fs.readDirectory(dir);
1509
+ for (const [name, type] of entries) {
1510
+ const path = dir ? `${dir}/${name}` : name;
1511
+ if (type === 2) {
1512
+ await walk(path);
1513
+ } else {
1514
+ out.push(path);
1515
+ }
1516
+ }
1517
+ };
1518
+
1519
+ await walk('');
1520
+ return out.sort();
1521
+ };
1522
+
1523
+ let viewerEditor: RuntimeCodeEditor | null = null;
1524
+ let viewerPath: string | null = null;
1525
+
1526
+ const setViewerHeader = (path: string | null) => {
1527
+ if (!viewerEditorContainer) return;
1528
+ const header = viewerEditorContainer.querySelector('[data-editorts-field="viewer-path"]') as HTMLElement | null;
1529
+ if (!header) return;
1530
+
1531
+ header.textContent = path ? `Viewing: ${path}` : '';
1532
+ };
1533
+
1534
+ const ensureViewerReady = async (path: string, content: string) => {
1535
+ if (!shouldEnableViewer || !viewerEditorContainer) return;
1536
+
1537
+ const host = viewerEditorContainer.querySelector('[data-editorts-field="viewer-editor"]') as HTMLElement | null;
1538
+ if (!host) return;
1539
+
1540
+ const language =
1541
+ path.endsWith('.css') ? 'css' :
1542
+ path.endsWith('.json') ? 'json' :
1543
+ path.endsWith('.js') ? 'javascript' :
1544
+ path.endsWith('.ts') || path.endsWith('.tsx') || path.endsWith('.jsx') ? 'typescript' :
1545
+ 'typescript';
1546
+
1547
+ if (!viewerEditor) {
1548
+ viewerEditor = await createCodeEditor(host, content, language, { readOnly: true });
1549
+ } else {
1550
+ viewerEditor.setValue(content);
1551
+ }
1552
+
1553
+ viewerPath = path;
1554
+ setViewerHeader(path);
1555
+ };
1556
+
1557
+ let filesListRenderNonce = 0;
1558
+
1559
+ const renderFilesList = async () => {
1560
+ if (!shouldEnableFilesViewer || !filesViewerContainer) return;
1561
+
1562
+ const renderNonce = ++filesListRenderNonce;
1563
+
1564
+ const listHost = filesViewerContainer.querySelector('[data-editorts-field="files-list"]') as HTMLElement | null;
1565
+ if (!listHost) return;
1566
+
1567
+ const filterInput = filesViewerContainer.querySelector('[data-editorts-field="files-filter"]') as HTMLInputElement | null;
1568
+ const rawFilter = filterInput?.value ?? '';
1569
+ const filter = rawFilter.trim().toLowerCase();
1570
+
1571
+ listHost.innerHTML = '';
1572
+
1573
+ if (!workspace) {
1574
+ listHost.textContent = 'Workspace not enabled';
1575
+ return;
1576
+ }
1577
+
1578
+ const files = await listWorkspaceFiles();
1579
+ if (renderNonce !== filesListRenderNonce) return;
1580
+
1581
+ const visibleFiles = filter
1582
+ ? files.filter((p) => p.toLowerCase().includes(filter))
1583
+ : files;
1584
+
1585
+ if (visibleFiles.length === 0) {
1586
+ listHost.textContent = filter ? 'No matches' : 'No files';
1587
+ return;
1588
+ }
1589
+
1590
+ visibleFiles.forEach((path) => {
1591
+ const btn = document.createElement('button');
1592
+ btn.type = 'button';
1593
+ btn.textContent = path;
1594
+ btn.style.display = 'block';
1595
+ btn.style.width = '100%';
1596
+ btn.style.textAlign = 'left';
1597
+ btn.style.padding = '0.25rem 0.5rem';
1598
+ btn.style.border = 'none';
1599
+ btn.style.borderRadius = '4px';
1600
+ btn.style.background = viewerPath === path ? 'rgba(59, 130, 246, 0.10)' : 'transparent';
1601
+ btn.style.cursor = 'pointer';
1602
+
1603
+ btn.addEventListener('click', () => {
1604
+ // Provide immediate visual feedback.
1605
+ viewerPath = path;
1606
+ void renderFilesList();
1607
+
1608
+ const targetTab: CodeTab =
1609
+ path === 'styles.css' ? 'css'
1610
+ : path === 'page.json' ? 'json'
1611
+ : path.startsWith('components/') && path.endsWith('.js') ? 'js'
1612
+ : 'viewer';
1613
+
1614
+ // Switch tabs immediately so the user sees something happen.
1615
+ // Also ensures Monaco hosts are visible before editor creation.
1616
+ setCodeTab?.(targetTab);
1617
+
1618
+ // For the viewer tab, show a quick placeholder while loading.
1619
+ if (targetTab === 'viewer' && viewerEditorContainer) {
1620
+ setViewerHeader(path);
1621
+ const host = viewerEditorContainer.querySelector('[data-editorts-field="viewer-editor"]') as HTMLElement | null;
1622
+ if (host && !viewerEditor) {
1623
+ host.innerHTML = '<pre style="margin:0; opacity:0.8;">Loading…</pre>';
1624
+ }
1625
+ }
1626
+
1627
+ void (async () => {
1628
+ if (!workspace) return;
1629
+
1630
+ try {
1631
+ const model = await workspace.openTextDocument(path);
1632
+ const value = model.getValue();
1633
+
1634
+ if (path === 'styles.css') {
1635
+ await ensureCssEditorReady();
1636
+ cssEditor?.setValue(value);
1637
+ cssEditor?.focus();
1638
+ return;
1639
+ }
1640
+
1641
+ if (path === 'page.json') {
1642
+ await ensureJsonEditorReady();
1643
+ jsonEditor?.setValue(value);
1644
+ jsonEditor?.focus();
1645
+ return;
1646
+ }
1647
+
1648
+ if (path.startsWith('components/') && path.endsWith('.js')) {
1649
+ const id = path.slice('components/'.length, -3);
1650
+ const component = page.components.findById(id);
1651
+ if (component) {
1652
+ iframe.contentWindow?.postMessage({ type: 'editorts:selectComponent', id }, '*');
1653
+ layerManager?.setSelected(id);
1654
+ }
1655
+
1656
+ await ensureJsEditorReadyFor(component);
1657
+ jsEditor?.setValue(value);
1658
+ jsEditor?.focus();
1659
+ return;
1660
+ }
1661
+
1662
+ await ensureViewerReady(path, value);
1663
+ viewerEditor?.focus();
1664
+ void renderFilesList();
1665
+ } catch (err: unknown) {
1666
+ const message = err instanceof Error ? err.message : String(err);
1667
+ console.warn(`Failed to open workspace file ${path}:`, message);
1668
+ }
1669
+ })();
1670
+ });
1671
+
1672
+ listHost.appendChild(btn);
1673
+ });
1674
+ };
1675
+
1676
+ async function ensureCssEditorReady() {
1677
+ if (!shouldEnableCssEditor || !cssEditorContainer) return;
1678
+ const host = cssEditorContainer.querySelector('[data-editorts-field="css-editor"]') as HTMLElement | null;
1679
+ if (!host) return;
1680
+
1681
+ if (!cssEditor) {
1682
+ cssEditor = await createCodeEditor(host, page.getCSS() ?? '', 'css');
1683
+ } else {
1684
+ cssEditor.setValue(page.getCSS() ?? '');
1685
+ }
1686
+ }
1687
+
1688
+ async function ensureJsonEditorReady() {
1689
+ if (!shouldEnableJsonEditor || !jsonEditorContainer) return;
1690
+ const host = jsonEditorContainer.querySelector('[data-editorts-field="json-editor"]') as HTMLElement | null;
1691
+ if (!host) return;
1692
+
1693
+ const nextValue = serializeData();
1694
+
1695
+ if (!jsonEditor) {
1696
+ jsonEditor = await createCodeEditor(host, nextValue, 'json');
1697
+ } else {
1698
+ jsonEditor.setValue(nextValue);
1699
+ }
1700
+ }
1701
+
1702
+ let jsxEditor: RuntimeCodeEditor | null = null;
1703
+
1704
+ async function ensureJsxEditorReady() {
1705
+ if (!shouldEnableJsxEditor || !jsxEditorContainer) return;
1706
+ const host = jsxEditorContainer.querySelector('[data-editorts-field="jsx-editor"]') as HTMLElement | null;
1707
+ if (!host) return;
1708
+
1709
+ const nextValue = page.components.toJSX({ pretty: true });
1710
+
1711
+ if (!jsxEditor) {
1712
+ jsxEditor = await createCodeEditor(host, nextValue, 'typescript');
1713
+ } else {
1714
+ jsxEditor.setValue(nextValue);
1715
+ }
1716
+ }
1717
+
1718
+ async function ensureJsEditorReadyFor(component: Component | null) {
1719
+ if (!shouldEnableJsEditor || !jsEditorContainer) return;
1720
+
1721
+ const status = jsEditorContainer.querySelector('[data-editorts-field="js-status"]') as HTMLElement | null;
1722
+ const host = jsEditorContainer.querySelector('[data-editorts-field="js-editor"]') as HTMLElement | null;
1723
+ if (!host) return;
1724
+
1725
+ if (!component) {
1726
+ selectedComponentId = null;
1727
+ if (status) status.textContent = 'Select a component to edit its script';
1728
+ if (!jsEditor) {
1729
+ jsEditor = await createCodeEditor(host, '', 'javascript');
1730
+ } else {
1731
+ jsEditor.setValue('');
1732
+ }
1733
+ return;
1734
+ }
1735
+
1736
+ selectedComponentId = component.attributes?.id ?? null;
1737
+ if (status) status.textContent = selectedComponentId ? `Editing: ${selectedComponentId}` : 'Editing: (no id)';
1738
+
1739
+ const nextValue = typeof component.script === 'string' ? component.script : '';
1740
+
1741
+ if (!jsEditor) {
1742
+ jsEditor = await createCodeEditor(host, nextValue, 'javascript');
1743
+ } else {
1744
+ jsEditor.setValue(nextValue);
1745
+ }
1746
+ }
1747
+
1748
+ function renderJsFileList() {
1749
+ if (!shouldEnableJsEditor || !jsEditorContainer) return;
1750
+
1751
+ const host = jsEditorContainer.querySelector('[data-editorts-field="js-files"]') as HTMLElement | null;
1752
+ if (!host) return;
1753
+
1754
+ host.innerHTML = '';
1755
+
1756
+ const collectIds = (components: Component[], out: string[]) => {
1757
+ components.forEach((component) => {
1758
+ const id = typeof component.attributes?.id === 'string' ? component.attributes.id : null;
1759
+ if (id) out.push(id);
1760
+ if (component.components && component.components.length > 0) {
1761
+ collectIds(component.components, out);
1762
+ }
1763
+ });
1764
+ };
1765
+
1766
+ const ids: string[] = [];
1767
+ collectIds(page.components.getAll(), ids);
1768
+ const uniqueIds = Array.from(new Set(ids));
1769
+
1770
+ if (uniqueIds.length === 0) {
1771
+ host.textContent = 'No components with ids';
1772
+ return;
1773
+ }
1774
+
1775
+ uniqueIds.forEach((id) => {
1776
+ const btn = document.createElement('button');
1777
+ btn.type = 'button';
1778
+ btn.textContent = id;
1779
+ btn.style.display = 'block';
1780
+ btn.style.width = '100%';
1781
+ btn.style.textAlign = 'left';
1782
+ btn.style.padding = '0.25rem 0.5rem';
1783
+ btn.style.border = 'none';
1784
+ btn.style.borderRadius = '4px';
1785
+ btn.style.background = id === selectedComponentId ? 'rgba(16,185,129,0.15)' : 'transparent';
1786
+ btn.style.cursor = 'pointer';
1787
+
1788
+ btn.addEventListener('click', () => {
1789
+ const component = page.components.findById(id);
1790
+ if (!component) return;
1791
+
1792
+ // Keep canvas + layers selection in sync
1793
+ iframe.contentWindow?.postMessage({ type: 'editorts:selectComponent', id }, '*');
1794
+ layerManager?.setSelected(id);
1795
+
1796
+ void ensureJsEditorReadyFor(component).then(() => {
1797
+ renderJsFileList();
1798
+ jsEditor?.focus();
1799
+ });
1800
+
1801
+ if (workspace) {
1802
+ const filename = `components/${id}.js`;
1803
+ void workspace.openTextDocument(filename, typeof component.script === 'string' ? component.script : '').then((model) => {
1804
+ jsEditor?.setValue(model.getValue());
1805
+ });
1806
+ }
1807
+ });
1808
+
1809
+ host.appendChild(btn);
1810
+ });
1811
+ }
1812
+
1813
+ // Wire Save/Export buttons
1814
+ if (shouldEnableFilesViewer && filesViewerContainer) {
1815
+ const btn = filesViewerContainer.querySelector('[data-editorts-action="refresh-files"]') as HTMLButtonElement | null;
1816
+ btn?.addEventListener('click', async () => {
1817
+ if (workspaceEnabled && !workspace) {
1818
+ try {
1819
+ const mod = await import('modern-monaco');
1820
+ await ensureWorkspace(mod);
1821
+ } catch (err: unknown) {
1822
+ const message = err instanceof Error ? err.message : String(err);
1823
+ console.warn('Failed to load modern-monaco workspace:', message);
1824
+ return;
1825
+ }
1826
+ }
1827
+
1828
+ await syncWorkspaceFiles();
1829
+ await renderFilesList();
1830
+ });
1831
+
1832
+ const filterInput = filesViewerContainer.querySelector('[data-editorts-field="files-filter"]') as HTMLInputElement | null;
1833
+ filterInput?.addEventListener('input', () => {
1834
+ void renderFilesList();
1835
+ });
1836
+ }
1837
+ if (shouldEnableJsxEditor && jsxEditorContainer) {
1838
+ const btn = jsxEditorContainer.querySelector('[data-editorts-action="export-jsx"]') as HTMLButtonElement | null;
1839
+ btn?.addEventListener('click', async () => {
1840
+ await ensureJsxEditorReady();
1841
+ jsxEditor?.focus();
1842
+ });
1843
+ }
1844
+
1845
+ if (shouldEnableCssEditor && cssEditorContainer) {
1846
+ const btn = cssEditorContainer.querySelector('[data-editorts-action="save-css"]') as HTMLButtonElement | null;
1847
+ btn?.addEventListener('click', async () => {
1848
+ await ensureCssEditorReady();
1849
+ if (!cssEditor) return;
1850
+
1851
+ page.setCSS(cssEditor.getValue());
1852
+
1853
+ if (workspace) {
1854
+ await workspace.fs.writeFile('styles.css', cssEditor.getValue(), { isModelContentChange: true });
1855
+ }
1856
+
1857
+ await commitSnapshot({ source: 'user', message: 'edit css' });
1858
+
1859
+ refresh();
1860
+ refreshLayers();
1861
+ });
1862
+ }
1863
+
1864
+ if (shouldEnableJsEditor && jsEditorContainer) {
1865
+ const btn = jsEditorContainer.querySelector('[data-editorts-action="save-js"]') as HTMLButtonElement | null;
1866
+ btn?.addEventListener('click', async () => {
1867
+ if (!selectedComponentId) return;
1868
+ const component = page.components.findById(selectedComponentId);
1869
+ if (!component) return;
1870
+
1871
+ await ensureJsEditorReadyFor(component);
1872
+ if (!jsEditor) return;
1873
+
1874
+ const nextValue = jsEditor.getValue();
1875
+ page.components.updateComponent(selectedComponentId, { script: nextValue });
1876
+
1877
+ if (workspace) {
1878
+ await workspace.fs.writeFile(`components/${selectedComponentId}.js`, nextValue, { isModelContentChange: true });
1879
+ }
1880
+
1881
+ await commitSnapshot({ source: 'user', message: 'edit component script' });
1882
+
1883
+ refresh();
1884
+ refreshLayers();
1885
+ });
1886
+ }
1887
+
1888
+ if (shouldEnableJsonEditor && jsonEditorContainer) {
1889
+ const btn = jsonEditorContainer.querySelector('[data-editorts-action="save-json"]') as HTMLButtonElement | null;
1890
+ btn?.addEventListener('click', async () => {
1891
+ await ensureJsonEditorReady();
1892
+ if (!jsonEditor) return;
1893
+
1894
+ const errorEl = jsonEditorContainer.querySelector('[data-editorts-field="json-error"]') as HTMLElement | null;
1895
+
1896
+ try {
1897
+ const next = JSON.parse(jsonEditor.getValue()) as PageData | MultiPageData;
1898
+
1899
+ const toolbarRuntimeConfig = page.toolbars.exportConfig();
1900
+
1901
+ if (isMultiPageData(next)) {
1902
+ if (!next.pages || next.pages.length === 0) throw new Error('MultiPageData.pages cannot be empty');
1903
+ multiPageData = next;
1904
+ activePageIndex = next.activePageIndex ?? 0;
1905
+ const loadedPageData = resolvePageData(next.pages[activePageIndex] ?? next.pages[0]!);
1906
+ const newPage = new Page(loadedPageData);
1907
+ Object.assign(page, newPage);
1908
+ } else {
1909
+ multiPageData = null;
1910
+ activePageIndex = 0;
1911
+ const newPage = new Page(resolvePageData(next as PageData));
1912
+ Object.assign(page, newPage);
1913
+ }
1914
+
1915
+ // Reapply runtime toolbar configuration
1916
+ page.toolbars.importConfig(toolbarRuntimeConfig);
1917
+
1918
+ if (errorEl) {
1919
+ errorEl.style.display = 'none';
1920
+ errorEl.textContent = '';
1921
+ }
1922
+
1923
+ if (workspace) {
1924
+ await workspace.fs.writeFile('page.json', jsonEditor.getValue(), { isModelContentChange: true });
1925
+ }
1926
+
1927
+ await commitSnapshot({ source: 'user', message: 'edit json' });
1928
+
1929
+ refresh();
1930
+ refreshLayers();
1931
+ } catch (err: unknown) {
1932
+ const message = err instanceof Error ? err.message : String(err);
1933
+
1934
+ if (errorEl) {
1935
+ errorEl.style.display = 'block';
1936
+ errorEl.textContent = message;
1937
+ }
1938
+ }
1939
+ });
1940
+ }
1941
+
1942
+ // Initial editor content
1943
+ void ensureCssEditorReady();
1944
+ void ensureJsonEditorReady();
1945
+ void ensureJsEditorReadyFor(null);
1946
+ void ensureJsxEditorReady();
1947
+ renderJsFileList();
1948
+
1949
+ if (workspace) {
1950
+ void renderFilesList();
1951
+ }
1952
+
1953
+ // Handle messages from iframe
1954
+ window.addEventListener('message', (event) => {
1955
+ if (event.data.type === 'editorts:componentSelected') {
1956
+ const component = page.components.findById(event.data.id);
1957
+ if (component) {
1958
+ // Update JS editor panel (if enabled)
1959
+ void ensureJsEditorReadyFor(component);
1960
+ renderJsFileList();
1961
+
1962
+ // Update selected info container if provided
1963
+ if (selectedInfoContainer && config.ui?.selectedInfo?.enabled !== false) {
1964
+ renderSelectedInfo(component, event.data.id, event.data.tagName);
1965
+ }
1966
+
1967
+ // Sync layer panel selection
1968
+ if (layerManager) {
1969
+ layerManager.setSelected(event.data.id);
1970
+ }
1971
+
1972
+ // Emit event
1973
+ emit('componentSelect', component);
1974
+ if (config.onComponentSelect) {
1975
+ config.onComponentSelect(component);
1976
+ }
1977
+ }
1978
+ } else if (event.data.type === 'editorts:getToolbar') {
1979
+ // Send toolbar config to iframe
1980
+ const component = page.components.findById(event.data.id);
1981
+ if (component) {
1982
+ const toolbarConfig = page.toolbars.getToolbarForComponent(component);
1983
+ iframe.contentWindow?.postMessage({
1984
+ type: 'editorts:toolbarConfig',
1985
+ config: toolbarConfig,
1986
+ elementId: event.data.id
1987
+ }, '*');
1988
+ }
1989
+ } else if (event.data.type === 'editorts:toolbarAction') {
1990
+ handleToolbarAction(event.data.action, event.data.elementId);
1991
+ } else if (event.data.type === 'editorts:canvasReorder') {
1992
+ const draggedId = event.data.draggedId as string;
1993
+ const targetId = event.data.targetId as string;
1994
+
1995
+ if (!draggedId || !targetId || draggedId === targetId) return;
1996
+
1997
+ const targetInfo = page.components.getParentAndIndex(targetId);
1998
+ if (!targetInfo) return;
1999
+
2000
+ const parentId = typeof event.data.targetParentId === 'string'
2001
+ ? event.data.targetParentId
2002
+ : targetInfo.parentId;
2003
+ const nextIndex = Number.isFinite(event.data.targetIndex)
2004
+ ? Number(event.data.targetIndex)
2005
+ : targetInfo.index;
2006
+
2007
+ page.components.moveComponent(draggedId, parentId, nextIndex);
2008
+
2009
+ const component = page.components.findById(draggedId);
2010
+ if (component) {
2011
+ emit('componentReorder', component, parentId, nextIndex);
2012
+ }
2013
+
2014
+ void commitSnapshot({ source: 'user', message: 'reorder component' });
2015
+
2016
+ refresh();
2017
+ refreshLayers();
2018
+ } else if (event.data.type === 'editorts:placeComponent') {
2019
+ const targetId = event.data.targetId as string;
2020
+ if (!pendingInsertType) return;
2021
+ const def = componentRegistry[pendingInsertType];
2022
+ if (!def) return;
2023
+
2024
+ const componentToInsert = def.factory();
2025
+
2026
+ // Insert as child of target for now.
2027
+ page.components.addChildComponent(targetId, componentToInsert);
2028
+
2029
+ pendingInsertType = null;
2030
+ componentPalette?.setSelected(null);
2031
+ iframe.contentWindow?.postMessage({ type: 'editorts:placementMode', enabled: false }, '*');
2032
+
2033
+ emit('componentInsert', componentToInsert, targetId);
2034
+
2035
+ void commitSnapshot({ source: 'user', message: 'insert component' });
2036
+
2037
+ refresh();
2038
+ refreshLayers();
2039
+
2040
+ // Flash/select the target so placement is obvious.
2041
+ iframe.contentWindow?.postMessage({ type: 'editorts:flashSelect', id: targetId }, '*');
2042
+ } else if (event.data.type === 'editorts:textEditStart') {
2043
+ const component = page.components.findById(event.data.id);
2044
+ if (component) {
2045
+ emit('textEditStart', component);
2046
+ if (config.onTextEditStart) {
2047
+ config.onTextEditStart(component);
2048
+ }
2049
+ }
2050
+ } else if (event.data.type === 'editorts:textUpdate') {
2051
+ const component = page.components.findById(event.data.id);
2052
+ if (component) {
2053
+ // Update the component's text content
2054
+ page.components.updateTextContent(event.data.id, event.data.content);
2055
+
2056
+ emit('textUpdate', component, event.data.content, event.data.originalContent);
2057
+ if (config.onTextUpdate) {
2058
+ config.onTextUpdate(component, event.data.content, event.data.originalContent);
2059
+ }
2060
+ refreshLayers();
2061
+ }
2062
+ } else if (event.data.type === 'editorts:textEditEnd') {
2063
+ const component = page.components.findById(event.data.id);
2064
+ if (component) {
2065
+ emit('textEditEnd', component, event.data.saved);
2066
+ if (config.onTextEditEnd) {
2067
+ config.onTextEditEnd(component, event.data.saved);
2068
+ }
2069
+
2070
+ if (event.data.saved) {
2071
+ void commitSnapshot({ source: 'user', message: 'edit text' });
2072
+ }
2073
+ refreshLayers();
2074
+ }
2075
+ } else if (event.data.type === 'editorts:imageEditStart') {
2076
+ const component = page.components.findById(event.data.id);
2077
+ if (component) {
2078
+ emit('imageEditStart', component, event.data.currentSrc);
2079
+ if (config.onImageEditStart) {
2080
+ config.onImageEditStart(component, event.data.currentSrc);
2081
+ }
2082
+ }
2083
+ } else if (event.data.type === 'editorts:imageUpdate') {
2084
+ const component = page.components.findById(event.data.id);
2085
+ if (component) {
2086
+ // Update the component's image src
2087
+ page.components.updateImageSrc(event.data.id, event.data.src);
2088
+
2089
+ const fileInfo = {
2090
+ fileName: event.data.fileName,
2091
+ fileType: event.data.fileType,
2092
+ fileSize: event.data.fileSize
2093
+ };
2094
+
2095
+ emit('imageUpdate', component, event.data.src, event.data.originalSrc, fileInfo);
2096
+ if (config.onImageUpdate) {
2097
+ config.onImageUpdate(component, event.data.src, event.data.originalSrc, fileInfo);
2098
+ }
2099
+ refreshLayers();
2100
+ }
2101
+ } else if (event.data.type === 'editorts:imageEditEnd') {
2102
+ const component = page.components.findById(event.data.id);
2103
+ if (component) {
2104
+ emit('imageEditEnd', component, event.data.saved);
2105
+ if (config.onImageEditEnd) {
2106
+ config.onImageEditEnd(component, event.data.saved);
2107
+ }
2108
+
2109
+ if (event.data.saved) {
2110
+ void commitSnapshot({ source: 'user', message: 'edit image' });
2111
+ }
2112
+ refreshLayers();
2113
+ }
2114
+ }
2115
+ });
2116
+
2117
+ function renderSelectedInfo(component: Component, elementId: string, tagName: string) {
2118
+ if (!selectedInfoContainer) return;
2119
+
2120
+ const selectedElement = iframe.contentDocument?.getElementById(elementId) as HTMLElement | null;
2121
+ const isPlainTextElement = !!selectedElement && selectedElement.childElementCount === 0;
2122
+
2123
+ // For text, only allow editing inner text (not HTML).
2124
+ // Also avoid wiping nested markup by requiring a plain-text element.
2125
+ const canEditText = isPlainTextElement && tagName?.toLowerCase() !== 'img';
2126
+
2127
+ const canEditImageSrc =
2128
+ tagName?.toLowerCase() === 'img' ||
2129
+ typeof component.attributes?.src === 'string' ||
2130
+ (selectedElement?.tagName.toLowerCase() === 'img');
2131
+
2132
+ selectedInfoContainer.innerHTML = `
2133
+ <div style="display:flex; flex-direction:column; gap:0.75rem;">
2134
+ <div>
2135
+ <div><strong>ID:</strong> ${elementId}</div>
2136
+ <div><strong>Tag:</strong> ${tagName}</div>
2137
+ </div>
2138
+
2139
+ <div>
2140
+ <div style="font-weight:600; margin-bottom:0.25rem;">Style</div>
2141
+ <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
2142
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2143
+ Margin top
2144
+ <input data-editorts-style="margin-top" type="text" placeholder="e.g. 16px" />
2145
+ </label>
2146
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2147
+ Margin bottom
2148
+ <input data-editorts-style="margin-bottom" type="text" placeholder="e.g. 16px" />
2149
+ </label>
2150
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2151
+ Padding top
2152
+ <input data-editorts-style="padding-top" type="text" placeholder="e.g. 24px" />
2153
+ </label>
2154
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2155
+ Padding bottom
2156
+ <input data-editorts-style="padding-bottom" type="text" placeholder="e.g. 24px" />
2157
+ </label>
2158
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2159
+ Width
2160
+ <input data-editorts-style="width" type="text" placeholder="e.g. 100%" />
2161
+ </label>
2162
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2163
+ Height
2164
+ <input data-editorts-style="height" type="text" placeholder="e.g. 300px" />
2165
+ </label>
2166
+
2167
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2168
+ Color
2169
+ <input data-editorts-style="color" type="text" placeholder="e.g. #111" />
2170
+ </label>
2171
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2172
+ Background
2173
+ <input data-editorts-style="background-color" type="text" placeholder="e.g. #f5f5f5" />
2174
+ </label>
2175
+
2176
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2177
+ Border
2178
+ <input data-editorts-style="border" type="text" placeholder="e.g. 1px solid #ddd" />
2179
+ </label>
2180
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2181
+ Border radius
2182
+ <input data-editorts-style="border-radius" type="text" placeholder="e.g. 12px" />
2183
+ </label>
2184
+
2185
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2186
+ Display
2187
+ <input data-editorts-style="display" type="text" placeholder="e.g. block" />
2188
+ </label>
2189
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2190
+ Gap
2191
+ <input data-editorts-style="gap" type="text" placeholder="e.g. 12px" />
2192
+ </label>
2193
+
2194
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2195
+ Font size
2196
+ <input data-editorts-style="font-size" type="text" placeholder="e.g. 18px" />
2197
+ </label>
2198
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2199
+ Font weight
2200
+ <input data-editorts-style="font-weight" type="text" placeholder="e.g. 600" />
2201
+ </label>
2202
+
2203
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2204
+ Text align
2205
+ <input data-editorts-style="text-align" type="text" placeholder="e.g. center" />
2206
+ </label>
2207
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2208
+ Transition
2209
+ <input data-editorts-style="transition" type="text" placeholder="e.g. all 150ms ease" />
2210
+ </label>
2211
+ </div>
2212
+
2213
+ <div style="display:flex; gap:0.5rem; margin-top:0.5rem;">
2214
+ <button data-editorts-action="apply-style">Apply</button>
2215
+ <button data-editorts-action="clear-style" type="button">Clear</button>
2216
+ </div>
2217
+ </div>
2218
+
2219
+ ${canEditText ? `
2220
+ <div>
2221
+ <div style="font-weight:600; margin-bottom:0.25rem;">Text</div>
2222
+ <textarea data-editorts-field="text-content" style="width:100%; min-height:6rem;"></textarea>
2223
+ <button data-editorts-action="apply-text" style="margin-top:0.25rem;">Apply</button>
2224
+ </div>
2225
+ ` : ''}
2226
+
2227
+ ${canEditImageSrc ? `
2228
+ <div>
2229
+ <div style="font-weight:600; margin-bottom:0.25rem;">Image URL</div>
2230
+ <input data-editorts-field="image-src" type="text" style="width:100%;" />
2231
+ <button data-editorts-action="apply-image-src" style="margin-top:0.25rem;">Apply</button>
2232
+ </div>
2233
+ ` : ''}
2234
+ </div>
2235
+ `;
2236
+
2237
+ // StyleManager stores selector objects as component IDs (without '#').
2238
+ // StyleManager.compileToCSS prefixes '#' for selector objects automatically.
2239
+ const selector = elementId;
2240
+
2241
+ const styleProps = [
2242
+ 'margin-top',
2243
+ 'margin-bottom',
2244
+ 'padding-top',
2245
+ 'padding-bottom',
2246
+ 'width',
2247
+ 'height',
2248
+ 'color',
2249
+ 'background-color',
2250
+ 'border',
2251
+ 'border-radius',
2252
+ 'display',
2253
+ 'gap',
2254
+ 'font-size',
2255
+ 'font-weight',
2256
+ 'text-align',
2257
+ 'transition',
2258
+ ];
2259
+
2260
+ const populateStyleField = (prop: string) => {
2261
+ const input = selectedInfoContainer.querySelector(`[data-editorts-style="${prop}"]`) as HTMLInputElement | null;
2262
+ if (!input) return;
2263
+
2264
+ const props = page.styles.getStyleProperties(selector);
2265
+ input.value = typeof props?.[prop] === 'string' ? String(props[prop]) : '';
2266
+ };
2267
+
2268
+ styleProps.forEach(populateStyleField);
2269
+
2270
+ const applyStyleButton = selectedInfoContainer.querySelector('[data-editorts-action="apply-style"]') as HTMLButtonElement | null;
2271
+ if (applyStyleButton) {
2272
+ applyStyleButton.addEventListener('click', async () => {
2273
+ const properties: Record<string, string> = {};
2274
+ const readProp = (prop: string) => {
2275
+ const input = selectedInfoContainer.querySelector(`[data-editorts-style="${prop}"]`) as HTMLInputElement | null;
2276
+ const value = input?.value.trim();
2277
+ if (value) properties[prop] = value;
2278
+ };
2279
+
2280
+ styleProps.forEach(readProp);
2281
+
2282
+ if (Object.keys(properties).length === 0) return;
2283
+
2284
+ const updated = page.styles.updateStyle(selector, properties);
2285
+ if (!updated) {
2286
+ page.styles.addStyle({
2287
+ selectors: [{ name: selector }],
2288
+ style: { ...properties },
2289
+ });
2290
+ }
2291
+
2292
+ page.styles.sync();
2293
+ const nextCss = page.getCSS() ?? '';
2294
+
2295
+ if (workspace) {
2296
+ await workspace.fs.writeFile('styles.css', nextCss, { isModelContentChange: true });
2297
+ await workspace.fs.writeFile('page.json', save(), { isModelContentChange: true });
2298
+ }
2299
+
2300
+ const styleEl = iframe.contentDocument?.querySelector('head style') as HTMLStyleElement | null;
2301
+ if (styleEl) styleEl.textContent = nextCss;
2302
+
2303
+ await commitSnapshot({ source: 'user', message: 'edit style' });
2304
+
2305
+ styleProps.forEach(populateStyleField);
2306
+
2307
+ void ensureCssEditorReady();
2308
+ void ensureJsonEditorReady();
2309
+ refreshLayers();
2310
+ });
2311
+ }
2312
+
2313
+ const clearStyleButton = selectedInfoContainer.querySelector('[data-editorts-action="clear-style"]') as HTMLButtonElement | null;
2314
+ if (clearStyleButton) {
2315
+ clearStyleButton.addEventListener('click', async () => {
2316
+ page.styles.removeBySelector(selector);
2317
+
2318
+ page.styles.sync();
2319
+ const nextCss = page.getCSS() ?? '';
2320
+
2321
+ if (workspace) {
2322
+ await workspace.fs.writeFile('styles.css', nextCss, { isModelContentChange: true });
2323
+ await workspace.fs.writeFile('page.json', save(), { isModelContentChange: true });
2324
+ }
2325
+
2326
+ const styleEl = iframe.contentDocument?.querySelector('head style') as HTMLStyleElement | null;
2327
+ if (styleEl) styleEl.textContent = nextCss;
2328
+
2329
+ await commitSnapshot({ source: 'user', message: 'clear style' });
2330
+
2331
+ styleProps.forEach((p) => {
2332
+ const input = selectedInfoContainer.querySelector(`[data-editorts-style="${p}"]`) as HTMLInputElement | null;
2333
+ if (input) input.value = '';
2334
+ });
2335
+
2336
+ void ensureCssEditorReady();
2337
+ void ensureJsonEditorReady();
2338
+ refreshLayers();
2339
+ });
2340
+ }
2341
+
2342
+ const textArea = selectedInfoContainer.querySelector('[data-editorts-field="text-content"]') as HTMLTextAreaElement | null;
2343
+ if (textArea) {
2344
+ textArea.value = selectedElement?.textContent ?? '';
2345
+ }
2346
+
2347
+ const imageSrcInput = selectedInfoContainer.querySelector('[data-editorts-field="image-src"]') as HTMLInputElement | null;
2348
+ if (imageSrcInput) {
2349
+ const currentImgEl =
2350
+ selectedElement?.tagName.toLowerCase() === 'img'
2351
+ ? (selectedElement as HTMLImageElement)
2352
+ : (selectedElement?.querySelector('img') as HTMLImageElement | null);
2353
+
2354
+ imageSrcInput.value = currentImgEl?.getAttribute('src') ?? component.attributes?.src ?? '';
2355
+ }
2356
+
2357
+ const applyTextButton = selectedInfoContainer.querySelector('[data-editorts-action="apply-text"]') as HTMLButtonElement | null;
2358
+ if (applyTextButton && textArea) {
2359
+ applyTextButton.addEventListener('click', () => {
2360
+ const nextText = textArea.value;
2361
+
2362
+ page.components.updateTextContent(elementId, nextText);
2363
+ if (selectedElement) {
2364
+ selectedElement.textContent = nextText;
2365
+ }
2366
+ refreshLayers();
2367
+ });
2368
+ }
2369
+
2370
+ const applyImageSrcButton = selectedInfoContainer.querySelector('[data-editorts-action="apply-image-src"]') as HTMLButtonElement | null;
2371
+ if (applyImageSrcButton && imageSrcInput) {
2372
+ applyImageSrcButton.addEventListener('click', () => {
2373
+ const nextSrc = imageSrcInput.value;
2374
+
2375
+ page.components.updateImageSrc(elementId, nextSrc);
2376
+
2377
+ if (selectedElement) {
2378
+ const imgEl =
2379
+ selectedElement.tagName.toLowerCase() === 'img'
2380
+ ? (selectedElement as HTMLImageElement)
2381
+ : (selectedElement.querySelector('img') as HTMLImageElement | null);
2382
+
2383
+ if (imgEl) {
2384
+ imgEl.src = nextSrc;
2385
+ }
2386
+ }
2387
+ refreshLayers();
2388
+ });
2389
+ }
2390
+ }
2391
+
2392
+ // Handle toolbar actions
2393
+ function handleToolbarAction(actionId: string, elementId: string) {
2394
+ const component = page.components.findById(elementId);
2395
+ if (!component) return;
2396
+
2397
+ switch (actionId) {
2398
+ case 'edit':
2399
+ emit('componentEdit', component);
2400
+ if (config.onComponentEdit) {
2401
+ config.onComponentEdit(component);
2402
+ }
2403
+ break;
2404
+
2405
+ case 'editJS':
2406
+ emit('componentEditJS', component);
2407
+ setView?.('code');
2408
+ setCodeTab?.('js');
2409
+ void ensureJsEditorReadyFor(component).then(() => jsEditor?.focus());
2410
+ break;
2411
+
2412
+ case 'editCSS':
2413
+ emit('pageEditCSS', page.getBody());
2414
+ setView?.('code');
2415
+ setCodeTab?.('css');
2416
+ void ensureCssEditorReady().then(() => cssEditor?.focus());
2417
+ break;
2418
+
2419
+ case 'editJSON':
2420
+ emit('pageEditJSON', page.getBody());
2421
+ setView?.('code');
2422
+ setCodeTab?.('json');
2423
+ void ensureJsonEditorReady().then(() => jsonEditor?.focus());
2424
+ break;
2425
+
2426
+ case 'duplicate':
2427
+ const clone = JSON.parse(JSON.stringify(component));
2428
+ clone.attributes = clone.attributes || {};
2429
+ clone.attributes.id = elementId + '-copy-' + Date.now();
2430
+ page.components.addComponent(clone);
2431
+
2432
+ emit('componentDuplicate', component, clone);
2433
+ if (config.onComponentDuplicate) {
2434
+ config.onComponentDuplicate(component, clone);
2435
+ }
2436
+
2437
+ void commitSnapshot({ source: 'user', message: 'duplicate component' });
2438
+
2439
+ refresh();
2440
+ refreshLayers();
2441
+ break;
2442
+
2443
+ case 'delete':
2444
+ page.components.removeComponent(elementId);
2445
+
2446
+
2447
+ emit('componentDelete', component);
2448
+ if (config.onComponentDelete) {
2449
+ config.onComponentDelete(component);
2450
+ }
2451
+
2452
+ void commitSnapshot({ source: 'user', message: 'delete component' });
2453
+
2454
+ // Notify iframe to remove element
2455
+ iframe.contentWindow?.postMessage({
2456
+ type: 'editorts:toolbarAction',
2457
+ action: 'delete',
2458
+ elementId: elementId
2459
+ }, '*');
2460
+
2461
+ refreshLayers();
2462
+ break;
2463
+ }
2464
+ }
2465
+
2466
+ const setActivePageIndex = (nextIndex: number) => {
2467
+ if (!multiPageData) return;
2468
+
2469
+ const boundedIndex = Math.max(0, Math.min(nextIndex, multiPageData.pages.length - 1));
2470
+ if (boundedIndex === activePageIndex) return;
2471
+
2472
+ // Persist current page changes before switching
2473
+ multiPageData.pages[activePageIndex] = page.toObject();
2474
+
2475
+ activePageIndex = boundedIndex;
2476
+ multiPageData.activePageIndex = boundedIndex;
2477
+
2478
+ const loadedPageData = resolvePageData(multiPageData.pages[activePageIndex] ?? multiPageData.pages[0]!);
2479
+ const newPage = new Page(loadedPageData);
2480
+ Object.assign(page, newPage);
2481
+
2482
+ // Switch to the corresponding per-page history tree when page key is known.
2483
+ if (versionControlEnabled && activeStorageKey) {
2484
+ void loadVersionState(activeStorageKey, activePageIndex);
2485
+ }
2486
+
2487
+ refresh();
2488
+ };
2489
+
2490
+ const defaultRenderPagesDropdown = (): void => {
2491
+ if (!shouldEnablePages || !pagesContainer) return;
2492
+
2493
+ // If not multipage, show empty.
2494
+ if (!multiPageData || multiPageData.pages.length <= 1) {
2495
+ pagesContainer.innerHTML = '';
2496
+ return;
2497
+ }
2498
+
2499
+ const options = multiPageData.pages
2500
+ .map((p, idx) => {
2501
+ const label = typeof p.title === 'string' && p.title.trim() ? p.title.trim() : `Page ${idx + 1}`;
2502
+ return `<option value="${idx}" ${idx === activePageIndex ? 'selected' : ''}>${label}</option>`;
2503
+ })
2504
+ .join('');
2505
+
2506
+ pagesContainer.innerHTML = `
2507
+ <label style="display:flex; flex-direction:column; gap:0.25rem; font-size:0.85rem;">
2508
+ Page
2509
+ <select data-editorts-field="pages-select" style="width:100%;">
2510
+ ${options}
2511
+ </select>
2512
+ </label>
2513
+ `;
2514
+
2515
+ const select = pagesContainer.querySelector('[data-editorts-field="pages-select"]') as HTMLSelectElement | null;
2516
+ select?.addEventListener('change', () => {
2517
+ const idx = Number(select.value);
2518
+ if (!Number.isFinite(idx)) return;
2519
+ setActivePageIndex(idx);
2520
+ });
2521
+ };
2522
+
2523
+ const renderPagesDropdown = () => {
2524
+ if (!shouldEnablePages || !pagesContainer) return;
2525
+
2526
+ const customRender = config.ui?.pages?.render;
2527
+ if (!customRender) {
2528
+ defaultRenderPagesDropdown();
2529
+ return;
2530
+ }
2531
+
2532
+ const pages = multiPageData?.pages ?? [];
2533
+ const props: PagesRenderProps = {
2534
+ container: pagesContainer,
2535
+ pages,
2536
+ activePageIndex,
2537
+ onSelect: (index) => setActivePageIndex(index),
2538
+ };
2539
+
2540
+ customRender(props);
2541
+ };
2542
+
2543
+ // Initial render for multipage dropdown (refresh() may not be called yet)
2544
+ renderPagesDropdown();
2545
+
2546
+ function refreshLayers() {
2547
+ if (layerManager) {
2548
+ layerManager.update(page.components.getAll());
2549
+ }
2550
+ }
2551
+
2552
+ // Refresh iframe and layer panel
2553
+ function refresh() {
2554
+ iframe.srcdoc = buildIframeContent();
2555
+ void syncWorkspaceFiles();
2556
+
2557
+ refreshLayers();
2558
+
2559
+ renderStats();
2560
+ renderPagesDropdown();
2561
+ void ensureCssEditorReady();
2562
+ void ensureJsonEditorReady();
2563
+ void ensureJsxEditorReady();
2564
+ void renderFilesList();
2565
+
2566
+ const selected = selectedComponentId ? page.components.findById(selectedComponentId) : null;
2567
+ void ensureJsEditorReadyFor(selected);
2568
+ renderJsFileList();
2569
+ }
2570
+
2571
+ // Version control (snapshot tree) persisted separately via StorageManager.
2572
+ const versionControlEnabled = config.versionControl?.enabled !== false;
2573
+ const versionControlMaxSnapshots = config.versionControl?.maxSnapshots;
2574
+
2575
+ const autoSaveConfig = config.autoSave;
2576
+ const autoSaveEnabled = autoSaveConfig?.enabled === true;
2577
+ const autoSaveEveryEdits = Math.max(1, autoSaveConfig?.everyEdits ?? 1);
2578
+ const autoSaveUiEnabled = autoSaveEnabled && config.ui?.autoSave?.enabled !== false;
2579
+
2580
+ let autoSaveEditCount = 0;
2581
+ let autoSaveInFlight: Promise<void> | null = null;
2582
+
2583
+ const updateAutoSaveProgress = (count: number) => {
2584
+ if (!autoSaveUiEnabled || !autoSaveProgressBar) return;
2585
+
2586
+ const progress = Math.min(1, Math.max(0, count / autoSaveEveryEdits));
2587
+ autoSaveProgressBar.style.width = `${Math.round(progress * 100)}%`;
2588
+ autoSaveProgressBar.style.opacity = progress > 0 ? '0.6' : '0';
2589
+ };
2590
+
2591
+ let versionStorageKey: string | null = null;
2592
+ let versionControl: VersionControl | null = null;
2593
+ let activeStorageKey: string | null = null;
2594
+
2595
+ if (commandPaletteEnabled) {
2596
+ const componentEntries: CommandPaletteEntry[] = Object.values(componentRegistry).map((def) => ({
2597
+ kind: 'component',
2598
+ type: def.type,
2599
+ label: def.label ?? def.type,
2600
+ action: () => undefined,
2601
+ }));
2602
+
2603
+ const customEntries: CommandPaletteEntry[] = (commandPaletteConfig?.items ?? []).map((item) => ({
2604
+ kind: item.type ?? 'command',
2605
+ label: item.title,
2606
+ action: item.action,
2607
+ }));
2608
+
2609
+ commandPaletteEntries = [...componentEntries, ...customEntries];
2610
+
2611
+ const renderHint = (text: string) => {
2612
+ if (commandPaletteHint) {
2613
+ commandPaletteHint.textContent = text;
2614
+ }
2615
+ };
2616
+
2617
+ const addComponentFromPalette = async (type: string) => {
2618
+ const def = componentRegistry[type];
2619
+ if (!def) return;
2620
+
2621
+ const newComponent = def.factory();
2622
+ const targetId = selectedComponentId ?? null;
2623
+
2624
+ if (targetId) {
2625
+ page.components.addChildComponent(targetId, newComponent);
2626
+ } else {
2627
+ page.components.addComponent(newComponent);
2628
+ }
2629
+
2630
+ emit('componentInsert', newComponent, targetId);
2631
+
2632
+ await commitSnapshot({ source: 'user', message: 'command palette add' });
2633
+ refresh();
2634
+ refreshLayers();
2635
+ closeCommandPalette();
2636
+ };
2637
+
2638
+ const updateCommandPaletteActiveStyles = () => {
2639
+ if (!commandPaletteResults) return;
2640
+ const buttons = Array.from(
2641
+ commandPaletteResults.querySelectorAll('button[data-editorts-palette-kind]')
2642
+ ) as HTMLButtonElement[];
2643
+
2644
+ buttons.forEach((button, index) => {
2645
+ const isActive = index === commandPaletteActiveIndex;
2646
+ button.dataset.editortsPaletteActive = isActive ? 'true' : 'false';
2647
+ button.setAttribute('aria-selected', isActive ? 'true' : 'false');
2648
+ button.style.background = isActive ? 'rgba(79,70,229,0.12)' : 'white';
2649
+ if (isActive) {
2650
+ commandPaletteInput?.setAttribute('aria-activedescendant', button.id);
2651
+ }
2652
+ });
2653
+ };
2654
+
2655
+ renderCommandPaletteResults = () => {
2656
+ if (!commandPaletteResults || isRenderingCommandPalette) return;
2657
+ isRenderingCommandPalette = true;
2658
+
2659
+ const query = commandPaletteInput?.value.trim().toLowerCase() ?? '';
2660
+
2661
+ const entries = commandPaletteEntries.filter((entry) => entry.label.toLowerCase().includes(query)
2662
+ || (entry.type ? entry.type.includes(query) : false));
2663
+
2664
+ commandPaletteResults.innerHTML = '';
2665
+ commandPaletteResults.setAttribute('role', 'listbox');
2666
+
2667
+ if (entries.length === 0) {
2668
+ const empty = document.createElement('div');
2669
+ empty.textContent = 'No matching components';
2670
+ empty.style.opacity = '0.6';
2671
+ commandPaletteResults.appendChild(empty);
2672
+ renderHint('No matches');
2673
+ isRenderingCommandPalette = false;
2674
+ return;
2675
+ }
2676
+
2677
+ commandPaletteActiveIndex = Math.max(0, Math.min(commandPaletteActiveIndex, entries.length - 1));
2678
+ renderHint('Press Enter to add to selected or to the page root.');
2679
+
2680
+ entries.forEach((entry, index) => {
2681
+ const button = document.createElement('button');
2682
+ button.type = 'button';
2683
+ button.id = `editorts-palette-option-${index}`;
2684
+ button.dataset.editortsPaletteKind = entry.kind;
2685
+ button.dataset.editortsPaletteLabel = entry.label;
2686
+ if (entry.type) {
2687
+ button.dataset.editortsPaletteType = entry.type;
2688
+ }
2689
+ button.tabIndex = 0;
2690
+ button.setAttribute('role', 'option');
2691
+ button.style.display = 'flex';
2692
+ button.style.width = '100%';
2693
+ button.style.justifyContent = 'space-between';
2694
+ button.style.alignItems = 'center';
2695
+ button.style.padding = '0.4rem 0.5rem';
2696
+ button.style.border = '1px solid rgba(0,0,0,0.08)';
2697
+ button.style.borderRadius = '6px';
2698
+ button.style.cursor = 'pointer';
2699
+ button.style.marginBottom = '0.35rem';
2700
+
2701
+ const label = document.createElement('span');
2702
+ label.textContent = entry.label;
2703
+
2704
+ const tag = document.createElement('span');
2705
+ tag.textContent = entry.type ?? entry.kind;
2706
+ tag.style.fontSize = '0.75rem';
2707
+ tag.style.opacity = '0.6';
2708
+
2709
+ button.appendChild(label);
2710
+ button.appendChild(tag);
2711
+
2712
+ button.addEventListener('click', () => {
2713
+ if (entry.kind === 'component' && entry.type) {
2714
+ void addComponentFromPalette(entry.type);
2715
+ return;
2716
+ }
2717
+
2718
+ const result = entry.action();
2719
+ if (result && typeof (result as Promise<void>).then === 'function') {
2720
+ void (result as Promise<void>);
2721
+ }
2722
+ closeCommandPalette();
2723
+ });
2724
+
2725
+ button.addEventListener('focus', () => {
2726
+ commandPaletteActiveIndex = index;
2727
+ updateCommandPaletteActiveStyles();
2728
+ });
2729
+
2730
+ button.addEventListener('keydown', (event) => {
2731
+ if (event.key === 'ArrowDown') {
2732
+ event.preventDefault();
2733
+ commandPaletteActiveIndex = Math.min(commandPaletteActiveIndex + 1, entries.length - 1);
2734
+ updateCommandPaletteActiveStyles();
2735
+ return;
2736
+ }
2737
+
2738
+ if (event.key === 'ArrowUp') {
2739
+ event.preventDefault();
2740
+ commandPaletteActiveIndex = Math.max(commandPaletteActiveIndex - 1, 0);
2741
+ updateCommandPaletteActiveStyles();
2742
+ return;
2743
+ }
2744
+
2745
+ if (event.key === 'Enter') {
2746
+ event.preventDefault();
2747
+ if (entry.kind === 'component' && entry.type) {
2748
+ void addComponentFromPalette(entry.type);
2749
+ return;
2750
+ }
2751
+
2752
+ const result = entry.action();
2753
+ if (result && typeof (result as Promise<void>).then === 'function') {
2754
+ void (result as Promise<void>);
2755
+ }
2756
+ closeCommandPalette();
2757
+ }
2758
+ });
2759
+
2760
+ commandPaletteResults.appendChild(button);
2761
+ });
2762
+
2763
+ updateCommandPaletteActiveStyles();
2764
+ isRenderingCommandPalette = false;
2765
+ };
2766
+
2767
+ openCommandPalette = () => {
2768
+ if (!commandPaletteContainer) return;
2769
+ isCommandPaletteOpen = true;
2770
+ commandPaletteActiveIndex = 0;
2771
+ commandPaletteContainer.style.display = 'flex';
2772
+ commandPaletteContainer.setAttribute('aria-hidden', 'false');
2773
+ commandPaletteInput?.focus();
2774
+ renderCommandPaletteResults();
2775
+ };
2776
+
2777
+ closeCommandPalette = () => {
2778
+ if (!commandPaletteContainer) return;
2779
+ isCommandPaletteOpen = false;
2780
+ commandPaletteContainer.style.display = 'none';
2781
+ commandPaletteContainer.setAttribute('aria-hidden', 'true');
2782
+ };
2783
+
2784
+ if (commandPaletteContainer) {
2785
+ commandPaletteContainer.style.display = 'none';
2786
+ commandPaletteContainer.setAttribute('aria-hidden', 'true');
2787
+ }
2788
+
2789
+ commandPaletteInput?.addEventListener('input', () => {
2790
+ commandPaletteActiveIndex = 0;
2791
+ renderCommandPaletteResults();
2792
+ });
2793
+
2794
+ const selectEntry = (entry: CommandPaletteEntry | undefined) => {
2795
+ if (!entry) return;
2796
+
2797
+ if (entry.kind === 'component' && entry.type) {
2798
+ void addComponentFromPalette(entry.type);
2799
+ return;
2800
+ }
2801
+
2802
+ const result = entry.action();
2803
+ if (result && typeof (result as Promise<void>).then === 'function') {
2804
+ void (result as Promise<void>);
2805
+ }
2806
+ closeCommandPalette();
2807
+ };
2808
+
2809
+ commandPaletteInput?.addEventListener('keydown', (event) => {
2810
+ if (event.key === 'Escape') {
2811
+ event.preventDefault();
2812
+ closeCommandPalette();
2813
+ return;
2814
+ }
2815
+
2816
+ if (event.key === 'ArrowDown') {
2817
+ event.preventDefault();
2818
+ commandPaletteActiveIndex = Math.min(commandPaletteActiveIndex + 1, commandPaletteEntries.length - 1);
2819
+ renderCommandPaletteResults();
2820
+ return;
2821
+ }
2822
+
2823
+ if (event.key === 'ArrowUp') {
2824
+ event.preventDefault();
2825
+ commandPaletteActiveIndex = Math.max(commandPaletteActiveIndex - 1, 0);
2826
+ renderCommandPaletteResults();
2827
+ return;
2828
+ }
2829
+
2830
+ if (event.key === 'Tab') {
2831
+ event.preventDefault();
2832
+ const options = commandPaletteResults?.querySelectorAll('[data-editorts-palette-kind]') ?? [];
2833
+ const node = options.item(commandPaletteActiveIndex) as HTMLElement | null;
2834
+ node?.focus();
2835
+ return;
2836
+ }
2837
+
2838
+ if (event.key === 'Enter') {
2839
+ event.preventDefault();
2840
+ const entry = commandPaletteEntries[commandPaletteActiveIndex];
2841
+ selectEntry(entry);
2842
+ }
2843
+ });
2844
+
2845
+ commandPaletteResults?.addEventListener('keydown', (event) => {
2846
+ if (event.key === 'ArrowDown') {
2847
+ event.preventDefault();
2848
+ commandPaletteActiveIndex = Math.min(commandPaletteActiveIndex + 1, commandPaletteEntries.length - 1);
2849
+ renderCommandPaletteResults();
2850
+ return;
2851
+ }
2852
+
2853
+ if (event.key === 'ArrowUp') {
2854
+ event.preventDefault();
2855
+ commandPaletteActiveIndex = Math.max(commandPaletteActiveIndex - 1, 0);
2856
+ renderCommandPaletteResults();
2857
+ return;
2858
+ }
2859
+
2860
+ if (event.key === 'Enter') {
2861
+ event.preventDefault();
2862
+ const entry = commandPaletteEntries[commandPaletteActiveIndex];
2863
+ selectEntry(entry);
2864
+ }
2865
+ });
2866
+
2867
+ commandPaletteContainer?.addEventListener('click', (event) => {
2868
+ if (event.target === commandPaletteContainer) {
2869
+ closeCommandPalette();
2870
+ }
2871
+ });
2872
+
2873
+ commandPaletteClose?.addEventListener('click', () => closeCommandPalette());
2874
+
2875
+ document.addEventListener('keydown', (event) => {
2876
+ if (isCommandPaletteOpen && event.key === 'Escape') {
2877
+ event.preventDefault();
2878
+ closeCommandPalette();
2879
+ }
2880
+ });
2881
+ }
2882
+
2883
+ const shortcutContext: ShortcutContext = {
2884
+ openCommandPalette: () => {
2885
+ if (!commandPaletteEnabled) return;
2886
+ openCommandPalette();
2887
+ },
2888
+ undo: async () => {
2889
+ if (versionControl && versionControl.canUndo()) {
2890
+ const snapshot = versionControl.undo();
2891
+ if (!snapshot) return;
2892
+ await checkoutSnapshot(snapshot);
2893
+ await persistVersionState();
2894
+ return;
2895
+ }
2896
+
2897
+ (document.getElementById('history-undo') as HTMLButtonElement | null)?.click();
2898
+ },
2899
+ redo: async () => {
2900
+ if (versionControl && versionControl.canRedo()) {
2901
+ const snapshot = versionControl.redo();
2902
+ if (!snapshot) return;
2903
+ await checkoutSnapshot(snapshot);
2904
+ await persistVersionState();
2905
+ return;
2906
+ }
2907
+
2908
+ (document.getElementById('history-redo') as HTMLButtonElement | null)?.click();
2909
+ },
2910
+ deleteSelected: async () => {
2911
+ const targetId = selectedComponentId ?? page.components.getAll()[0]?.attributes?.id ?? null;
2912
+ if (!targetId) return;
2913
+ handleToolbarAction('delete', targetId);
2914
+ },
2915
+ };
2916
+
2917
+ const editorShortcuts = [
2918
+ ...createEditorShortcuts({
2919
+ undo: shortcutContext.undo,
2920
+ redo: shortcutContext.redo,
2921
+ deleteSelected: shortcutContext.deleteSelected,
2922
+ }),
2923
+ ...(config.shortcuts ?? []),
2924
+ ];
2925
+
2926
+ const paletteShortcuts = commandPaletteEnabled
2927
+ ? [
2928
+ ...createCommandPaletteShortcuts({ openCommandPalette: shortcutContext.openCommandPalette }),
2929
+ ...(config.ui?.commandPalette?.shortcuts ?? []),
2930
+ ]
2931
+ : [];
2932
+
2933
+ const keyboardShortcuts = new KeyboardShortcuts({
2934
+ shortcuts: [...paletteShortcuts, ...editorShortcuts],
2935
+ modKey: config.shortcutConfig?.modKey ?? 'ctrl',
2936
+ shouldIgnore: (event) => {
2937
+ if (isCommandPaletteOpen) {
2938
+ event.preventDefault();
2939
+ return true;
2940
+ }
2941
+ return false;
2942
+ },
2943
+ });
2944
+ keyboardShortcuts.bind(document);
2945
+ if (iframe.contentDocument) {
2946
+ keyboardShortcuts.bind(iframe.contentDocument);
2947
+ }
2948
+ iframe.addEventListener('load', () => {
2949
+ if (iframe.contentDocument) {
2950
+ keyboardShortcuts.bind(iframe.contentDocument);
2951
+ }
2952
+ });
2953
+
2954
+ const captureSnapshot = (): PageData => {
2955
+ // Page.toObject() returns a live reference; clone to keep history stable.
2956
+ return JSON.parse(JSON.stringify(page.toObject())) as PageData;
2957
+ };
2958
+
2959
+ if (versionControlEnabled) {
2960
+ versionControl = new VersionControl({ maxSnapshots: versionControlMaxSnapshots });
2961
+ versionControl.init(captureSnapshot(), { source: 'system', message: 'init' });
2962
+ }
2963
+
2964
+ const getHistoryKey = (pageKey: string, pageIndex: number) => {
2965
+ return `history:${pageKey}:${pageIndex}`;
2966
+ };
2967
+
2968
+ // Note: version history is persisted separately via StorageManager at
2969
+ // `history:<pageKey>:<pageIndex>`, never inside the PageData JSON.
2970
+ const serializeVersionState = (): string | null => {
2971
+ if (!versionControl) return null;
2972
+ return JSON.stringify(versionControl.getState());
2973
+ };
2974
+
2975
+ const loadVersionState = async (pageKey: string, pageIndex: number) => {
2976
+ if (!versionControlEnabled) return;
2977
+
2978
+ versionStorageKey = getHistoryKey(pageKey, pageIndex);
2979
+ const raw = await storage.loadPage(versionStorageKey);
2980
+
2981
+ if (raw) {
2982
+ try {
2983
+ const parsed = JSON.parse(raw) as unknown;
2984
+ if (parsed && typeof parsed === 'object') {
2985
+ versionControl = VersionControl.fromState(parsed as unknown as import('./VersionControl').VersionControlState, {
2986
+ maxSnapshots: versionControlMaxSnapshots,
2987
+ });
2988
+ return;
2989
+ }
2990
+ } catch {
2991
+ // ignore
2992
+ }
2993
+ }
2994
+
2995
+ versionControl = new VersionControl({ maxSnapshots: versionControlMaxSnapshots });
2996
+ versionControl.init(captureSnapshot(), { source: 'system', message: 'init' });
2997
+
2998
+ await storage.savePage(versionStorageKey, JSON.stringify(versionControl.getState()));
2999
+ };
3000
+
3001
+ const persistVersionState = async () => {
3002
+ if (!versionControlEnabled) return;
3003
+ if (!versionControl || !versionStorageKey) return;
3004
+ await storage.savePage(versionStorageKey, JSON.stringify(versionControl.getState()));
3005
+ };
3006
+
3007
+ const triggerAutoSave = async () => {
3008
+ if (!autoSaveEnabled) return;
3009
+
3010
+ autoSaveEditCount += 1;
3011
+ updateAutoSaveProgress(autoSaveEditCount);
3012
+
3013
+ if (autoSaveEditCount < autoSaveEveryEdits) return;
3014
+
3015
+ autoSaveEditCount = 0;
3016
+ updateAutoSaveProgress(autoSaveEveryEdits);
3017
+
3018
+ const key = autoSaveConfig?.key ?? activeStorageKey;
3019
+ if (!key) return;
3020
+
3021
+ if (!autoSaveInFlight) {
3022
+ autoSaveInFlight = saveTo(key)
3023
+ .catch((err: unknown) => {
3024
+ const message = err instanceof Error ? err.message : String(err);
3025
+ console.warn('EditorTs: auto-save failed:', message);
3026
+ })
3027
+ .finally(() => {
3028
+ autoSaveInFlight = null;
3029
+ });
3030
+ }
3031
+
3032
+ await autoSaveInFlight;
3033
+
3034
+ setTimeout(() => updateAutoSaveProgress(0), 150);
3035
+ };
3036
+
3037
+ const commitSnapshot = async (meta?: { source?: 'user' | 'ai' | 'system'; message?: string }) => {
3038
+ await triggerAutoSave();
3039
+
3040
+ if (!versionControlEnabled || !versionControl) return;
3041
+
3042
+ versionControl.commit(captureSnapshot(), meta);
3043
+
3044
+ // Persist only when a storage key is known.
3045
+ await persistVersionState();
3046
+ };
3047
+
3048
+ if (config.initialStorageKey) {
3049
+ void loadFrom(config.initialStorageKey);
3050
+ }
3051
+
3052
+ const checkoutSnapshot = async (snapshot: PageData) => {
3053
+ const toolbarRuntimeConfig = page.toolbars.exportConfig();
3054
+
3055
+ // Never mutate the snapshot stored in version control.
3056
+ const nextSnapshot = JSON.parse(JSON.stringify(snapshot)) as PageData;
3057
+
3058
+ const newPage = new Page(resolvePageData(nextSnapshot));
3059
+ Object.assign(page, newPage);
3060
+ page.toolbars.importConfig(toolbarRuntimeConfig);
3061
+
3062
+ refresh();
3063
+ };
3064
+
3065
+ function serializeData(): string {
3066
+ if (!multiPageData) {
3067
+ return page.toJSON();
3068
+ }
3069
+
3070
+ multiPageData.pages[activePageIndex] = page.toObject();
3071
+ return JSON.stringify(multiPageData, null, 2);
3072
+ }
3073
+
3074
+ // Save page data (returns JSON string)
3075
+ function save(): string {
3076
+ return serializeData();
3077
+ }
3078
+
3079
+ // Save page to storage
3080
+ async function saveTo(key: string): Promise<void> {
3081
+ const data = serializeData();
3082
+ await storage.savePage(key, data);
3083
+
3084
+ activeStorageKey = key;
3085
+
3086
+ // Persist history alongside the page data.
3087
+ if (versionControlEnabled) {
3088
+ // Default to active page index for multipage.
3089
+ const pageIndex = multiPageData ? activePageIndex : 0;
3090
+ if (!versionStorageKey) {
3091
+ await loadVersionState(key, pageIndex);
3092
+ }
3093
+ await persistVersionState();
3094
+ }
3095
+
3096
+ emit('pageSaved', key);
3097
+ }
3098
+
3099
+ // Load page from storage
3100
+ async function loadFrom(key: string): Promise<boolean> {
3101
+ activeStorageKey = key;
3102
+ const data = await storage.loadPage(key);
3103
+ if (!data) return false;
3104
+
3105
+ const parsed = JSON.parse(data) as PageData | MultiPageData;
3106
+
3107
+ if (isMultiPageData(parsed)) {
3108
+ if (parsed.pages.length === 0) {
3109
+ throw new Error('MultiPageData.pages cannot be empty');
3110
+ }
3111
+
3112
+ multiPageData = parsed;
3113
+ activePageIndex = parsed.activePageIndex ?? 0;
3114
+
3115
+ const loadedPageData = resolvePageData(parsed.pages[activePageIndex] ?? parsed.pages[0]!);
3116
+ const newPage = new Page(loadedPageData);
3117
+ Object.assign(page, newPage);
3118
+ } else {
3119
+ multiPageData = null;
3120
+ activePageIndex = 0;
3121
+
3122
+ const newPage = new Page(resolvePageData(parsed as PageData));
3123
+ Object.assign(page, newPage);
3124
+ }
3125
+
3126
+ if (versionControlEnabled) {
3127
+ const pageIndex = multiPageData ? activePageIndex : 0;
3128
+ await loadVersionState(key, pageIndex);
3129
+ }
3130
+
3131
+ refresh();
3132
+ emit('pageLoaded', key);
3133
+ return true;
3134
+ }
3135
+
3136
+ // Destroy editor
3137
+ function destroy() {
3138
+ iframe.srcdoc = '';
3139
+
3140
+ jsEditor?.dispose();
3141
+ cssEditor?.dispose();
3142
+ jsonEditor?.dispose();
3143
+ jsxEditor?.dispose();
3144
+
3145
+ void ai?.close();
3146
+
3147
+ if (sidebarContainer) sidebarContainer.innerHTML = '';
351
3148
  if (statsContainer) statsContainer.innerHTML = '';
352
3149
  if (selectedInfoContainer) selectedInfoContainer.innerHTML = '';
353
-
354
- Object.keys(eventListeners).forEach(key => {
3150
+ if (jsEditorContainer) jsEditorContainer.innerHTML = '';
3151
+ if (cssEditorContainer) cssEditorContainer.innerHTML = '';
3152
+ if (jsonEditorContainer) jsonEditorContainer.innerHTML = '';
3153
+ if (jsxEditorContainer) jsxEditorContainer.innerHTML = '';
3154
+ if (layerManager) layerManager.destroy();
3155
+ componentPalette?.destroy();
3156
+
3157
+ (Object.keys(eventListeners) as EditorTsEventName[]).forEach((key) => {
355
3158
  eventListeners[key] = [];
356
3159
  });
357
3160
  }
@@ -359,11 +3162,41 @@ ${page.getHTML()}
359
3162
  // Return EditorTsEditor instance
360
3163
  return {
361
3164
  page,
3165
+ storage,
3166
+ ai,
3167
+ versionControl: versionControlEnabled ? {
3168
+ enabled: true,
3169
+ canUndo: () => !!versionControl && versionControl.canUndo(),
3170
+ canRedo: () => !!versionControl && versionControl.canRedo(),
3171
+ undo: async () => {
3172
+ if (!versionControl) return false;
3173
+ const snapshot = versionControl.undo();
3174
+ if (!snapshot) return false;
3175
+ await checkoutSnapshot(snapshot);
3176
+ await persistVersionState();
3177
+ return true;
3178
+ },
3179
+ redo: async () => {
3180
+ if (!versionControl) return false;
3181
+ const snapshot = versionControl.redo();
3182
+ if (!snapshot) return false;
3183
+ await checkoutSnapshot(snapshot);
3184
+ await persistVersionState();
3185
+ return true;
3186
+ },
3187
+ commit: async (meta) => {
3188
+ await commitSnapshot(meta);
3189
+ },
3190
+ } : undefined,
3191
+ components: componentRegistry,
362
3192
  on,
363
3193
  off,
364
3194
  refresh,
365
3195
  save,
3196
+ saveTo,
3197
+ loadFrom,
366
3198
  destroy,
3199
+ vimMode,
367
3200
  elements: {
368
3201
  iframe,
369
3202
  sidebar: sidebarContainer || undefined,