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/README.md +197 -318
- package/index.ts +70 -0
- package/package.json +31 -10
- package/src/core/ComponentManager.ts +697 -6
- package/src/core/ComponentPalette.ts +109 -0
- package/src/core/CustomComponentRegistry.ts +74 -0
- package/src/core/KeyboardShortcuts.ts +220 -0
- package/src/core/LayerManager.ts +378 -0
- package/src/core/Page.ts +24 -5
- package/src/core/StorageManager.ts +447 -0
- package/src/core/StyleManager.ts +38 -2
- package/src/core/VersionControl.ts +189 -0
- package/src/core/aiChat.ts +427 -0
- package/src/core/iframeCanvas.ts +672 -0
- package/src/core/init.ts +3081 -248
- package/src/server/bun_server.ts +155 -0
- package/src/server/cf_worker.ts +225 -0
- package/src/server/schema.ts +21 -0
- package/src/server/sync.ts +195 -0
- package/src/types/sqlocal.d.ts +6 -0
- package/src/types.ts +591 -18
- package/src/utils/toolbar.ts +15 -1
package/src/core/init.ts
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { Page } from './Page';
|
|
7
|
-
import
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
65
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
242
|
+
const componentPaletteContainer = config.ui?.componentPalette?.containerId
|
|
243
|
+
? document.getElementById(config.ui.componentPalette.containerId)
|
|
244
|
+
: null;
|
|
182
245
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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 (
|
|
237
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
//
|
|
251
|
-
|
|
438
|
+
// Initialize component palette if container provided
|
|
439
|
+
let componentPalette: ComponentPalette | null = null;
|
|
440
|
+
let pendingInsertType: string | null = null;
|
|
252
441
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (!component) return;
|
|
540
|
+
if (shouldEnableAiChatUi && aiChatExpandButton) {
|
|
541
|
+
const initial = aiChatConfig?.defaultExpanded === true;
|
|
542
|
+
setAiChatExpanded(initial);
|
|
292
543
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
break;
|
|
551
|
+
// Initialize storage manager early so AI UI helpers can access it.
|
|
552
|
+
const storage = new StorageManager(config.storage);
|
|
304
553
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
iframe.srcdoc = iframeContent;
|
|
340
|
-
}
|
|
626
|
+
return { current, sessions };
|
|
627
|
+
};
|
|
341
628
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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,
|