vibespot 1.6.5 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -3
- package/assets/whats-new.json +27 -0
- package/dist/index.js +579 -681
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
- package/ui/chat.js +671 -9
- package/ui/dashboard.js +53 -12
- package/ui/docs/index.html +57 -2
- package/ui/email-preview.js +1 -5
- package/ui/escape-html.js +14 -0
- package/ui/field-editor.js +6 -12
- package/ui/field-save.js +82 -0
- package/ui/index.html +10 -4
- package/ui/inline-edit.js +116 -570
- package/ui/plan.js +22 -13
- package/ui/preview-agent.js +1050 -0
- package/ui/preview.js +248 -265
- package/ui/section-controls.js +16 -622
- package/ui/setup.js +73 -20
- package/ui/styles.css +424 -0
- package/ui/upload-panel.js +7 -8
- package/ui/whats-new.js +249 -0
- package/assets/readme/00-hero-banner.png +0 -0
- package/assets/readme/00-hero-banner.svg +0 -59
- package/assets/readme/01-vibe-coding-hero.png +0 -0
- package/assets/readme/02-plan-mode.png +0 -0
- package/assets/readme/03-figma-import.png +0 -0
- package/assets/readme/04-multi-page-sites.png +0 -0
- package/assets/readme/05-inline-wysiwyg.png +0 -0
- package/assets/readme/06-hubspot-upload.png +0 -0
- package/ui/docs/screenshots/brand-kit-preview.png +0 -0
- package/ui/docs/screenshots/content-type-dropdown.png +0 -0
- package/ui/docs/screenshots/editor-full-layout.png +0 -0
- package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
- package/ui/docs/screenshots/module-overview-slideout.png +0 -0
- package/ui/docs/screenshots/multi-page-tree.png +0 -0
- package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
- package/ui/docs/screenshots/split-pane-view.png +0 -0
- package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
- package/ui/docs/screenshots/workspace-tabs.png +0 -0
package/ui/inline-edit.js
CHANGED
|
@@ -1,483 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified interact mode —
|
|
2
|
+
* Unified interact mode — parent end (VIB-1892).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The hover/edit UI itself lives in the cross-origin preview frame and is run
|
|
5
|
+
* by the trusted agent (ui/preview-agent.js). This file owns the toolbar
|
|
6
|
+
* toggle, tells the agent which mode is active (vs:set-mode via preview.js),
|
|
7
|
+
* and maps the agent's committed edits (vs:edit-commit / vs:select-module)
|
|
8
|
+
* onto module fields via /api/field. The frame can only ever request the
|
|
9
|
+
* field-edit writes handled here — nothing else crosses the boundary.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
const interactBtn = document.getElementById("interact-mode-toggle");
|
|
9
13
|
let interactModeActive = false;
|
|
10
|
-
let interactHandlers = null;
|
|
11
|
-
|
|
12
|
-
function ensureInteractStyles(doc) {
|
|
13
|
-
if (doc.getElementById("vibespot-interact-css")) return;
|
|
14
|
-
const style = doc.createElement("style");
|
|
15
|
-
style.id = "vibespot-interact-css";
|
|
16
|
-
style.textContent = `
|
|
17
|
-
html.vibespot-interact-mode { cursor: default; }
|
|
18
|
-
.vibespot-editable-hover {
|
|
19
|
-
outline: 2px dashed rgba(59, 130, 246, 0.6) !important;
|
|
20
|
-
outline-offset: 2px !important;
|
|
21
|
-
cursor: text !important;
|
|
22
|
-
}
|
|
23
|
-
.vibespot-editable-hover[data-edit-type="image"] {
|
|
24
|
-
cursor: pointer !important;
|
|
25
|
-
}
|
|
26
|
-
.vibespot-editable-hover[data-edit-type="select"] {
|
|
27
|
-
outline-color: #e8613a !important;
|
|
28
|
-
outline-style: solid !important;
|
|
29
|
-
background-color: rgba(232, 97, 58, 0.08) !important;
|
|
30
|
-
cursor: crosshair !important;
|
|
31
|
-
}
|
|
32
|
-
.vibespot-editing {
|
|
33
|
-
outline: 2px solid rgba(59, 130, 246, 0.9) !important;
|
|
34
|
-
outline-offset: 2px !important;
|
|
35
|
-
background-color: rgba(59, 130, 246, 0.04) !important;
|
|
36
|
-
min-height: 1em;
|
|
37
|
-
}
|
|
38
|
-
.vibespot-edit-label {
|
|
39
|
-
position: fixed;
|
|
40
|
-
z-index: 2147483647;
|
|
41
|
-
pointer-events: none;
|
|
42
|
-
font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
43
|
-
color: #fff;
|
|
44
|
-
padding: 2px 7px;
|
|
45
|
-
border-radius: 3px;
|
|
46
|
-
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
|
47
|
-
white-space: nowrap;
|
|
48
|
-
}
|
|
49
|
-
.vibespot-edit-label--edit {
|
|
50
|
-
background: #3b82f6;
|
|
51
|
-
}
|
|
52
|
-
.vibespot-edit-label--select {
|
|
53
|
-
background: #e8613a;
|
|
54
|
-
}
|
|
55
|
-
.vibespot-image-edit-input {
|
|
56
|
-
position: fixed;
|
|
57
|
-
z-index: 2147483647;
|
|
58
|
-
font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif;
|
|
59
|
-
padding: 6px 10px;
|
|
60
|
-
border: 2px solid #3b82f6;
|
|
61
|
-
border-radius: 6px;
|
|
62
|
-
background: #1c1917;
|
|
63
|
-
color: #fff;
|
|
64
|
-
width: 320px;
|
|
65
|
-
outline: none;
|
|
66
|
-
}
|
|
67
|
-
.vibespot-link-edit-popup {
|
|
68
|
-
position: fixed;
|
|
69
|
-
z-index: 2147483647;
|
|
70
|
-
background: #1c1917;
|
|
71
|
-
border: 1px solid rgba(255,255,255,0.15);
|
|
72
|
-
border-radius: 8px;
|
|
73
|
-
padding: 10px;
|
|
74
|
-
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
|
75
|
-
display: flex;
|
|
76
|
-
flex-direction: column;
|
|
77
|
-
gap: 6px;
|
|
78
|
-
}
|
|
79
|
-
.vibespot-link-edit-popup label {
|
|
80
|
-
font: 500 11px/1.4 -apple-system, sans-serif;
|
|
81
|
-
color: rgba(255,255,255,0.6);
|
|
82
|
-
}
|
|
83
|
-
.vibespot-link-edit-popup input {
|
|
84
|
-
font: 13px/1.4 -apple-system, sans-serif;
|
|
85
|
-
padding: 5px 8px;
|
|
86
|
-
border: 1px solid rgba(255,255,255,0.2);
|
|
87
|
-
border-radius: 4px;
|
|
88
|
-
background: #0c0a09;
|
|
89
|
-
color: #fff;
|
|
90
|
-
outline: none;
|
|
91
|
-
width: 260px;
|
|
92
|
-
}
|
|
93
|
-
.vibespot-link-edit-popup input:focus { border-color: #3b82f6; }
|
|
94
|
-
.vibespot-link-edit-popup .vibespot-link-edit-actions {
|
|
95
|
-
display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px;
|
|
96
|
-
}
|
|
97
|
-
.vibespot-link-edit-popup button {
|
|
98
|
-
font: 500 12px/1 -apple-system, sans-serif;
|
|
99
|
-
padding: 5px 12px;
|
|
100
|
-
border: none; border-radius: 4px;
|
|
101
|
-
cursor: pointer;
|
|
102
|
-
}
|
|
103
|
-
.vibespot-link-edit-popup .vibespot-btn-save {
|
|
104
|
-
background: #3b82f6; color: #fff;
|
|
105
|
-
}
|
|
106
|
-
.vibespot-link-edit-popup .vibespot-btn-cancel {
|
|
107
|
-
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7);
|
|
108
|
-
}
|
|
109
|
-
`;
|
|
110
|
-
doc.head.appendChild(style);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Determine if element is directly editable (text/image/link) or module-level
|
|
114
|
-
function getInteractInfo(el) {
|
|
115
|
-
if (!el) return null;
|
|
116
|
-
const tag = (el.tagName || "").toLowerCase();
|
|
117
|
-
|
|
118
|
-
// Check for explicit field annotations first
|
|
119
|
-
const annotated = el.closest("[data-vs-field]");
|
|
120
|
-
if (annotated) {
|
|
121
|
-
const vsType = annotated.getAttribute("data-vs-type");
|
|
122
|
-
if (vsType === "image") return { type: "image", el: annotated, action: "edit" };
|
|
123
|
-
if (vsType === "link") return { type: "link", el: annotated, action: "edit" };
|
|
124
|
-
return { type: "text", el: annotated, action: "edit" };
|
|
125
|
-
}
|
|
126
|
-
const linkAnnotated = el.closest("[data-vs-link]");
|
|
127
|
-
if (linkAnnotated) return { type: "link", el: linkAnnotated, action: "edit" };
|
|
128
|
-
|
|
129
|
-
// Heuristic detection for editable elements
|
|
130
|
-
if (tag === "img") return { type: "image", el, action: "edit" };
|
|
131
|
-
if (tag === "a" || (el.closest && el.closest("a"))) {
|
|
132
|
-
const anchor = tag === "a" ? el : el.closest("a");
|
|
133
|
-
return { type: "link", el: anchor, action: "edit" };
|
|
134
|
-
}
|
|
135
|
-
if (tag.match(/^h[1-6]$/) || tag === "p" || tag === "span" || tag === "li" || tag === "td" || tag === "th") {
|
|
136
|
-
if (el.children.length === 0 || (el.children.length === 1 && el.children[0].tagName === "BR")) {
|
|
137
|
-
return { type: "text", el, action: "edit" };
|
|
138
|
-
}
|
|
139
|
-
if (el.textContent && el.textContent.trim().length > 0 && el.childElementCount <= 2) {
|
|
140
|
-
return { type: "text", el, action: "edit" };
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (tag === "button") return { type: "text", el, action: "edit" };
|
|
144
|
-
|
|
145
|
-
// Module-level container → select mode (prefill chat)
|
|
146
|
-
const moduleEl = el.closest("[data-module]");
|
|
147
|
-
if (moduleEl) return { type: "select", el: moduleEl, action: "select" };
|
|
148
|
-
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function describeElement(el) {
|
|
153
|
-
if (!el) return "";
|
|
154
|
-
const moduleEl = el.closest("[data-module]");
|
|
155
|
-
const moduleName = moduleEl ? moduleEl.getAttribute("data-module") : null;
|
|
156
|
-
const tag = (el.tagName || "").toLowerCase();
|
|
157
|
-
let kind = tag;
|
|
158
|
-
if (tag.match(/^h[1-6]$/)) kind = "headline";
|
|
159
|
-
else if (tag === "p") kind = "paragraph";
|
|
160
|
-
else if (tag === "a") kind = "link";
|
|
161
|
-
else if (tag === "button") kind = "button";
|
|
162
|
-
else if (tag === "img") kind = "image";
|
|
163
|
-
else if (tag === "ul" || tag === "ol") kind = "list";
|
|
164
|
-
else if (tag === "li") kind = "list item";
|
|
165
|
-
|
|
166
|
-
if (moduleName && moduleEl === el) return moduleName;
|
|
167
|
-
if (moduleName) return `${moduleName} > ${kind}`;
|
|
168
|
-
return kind;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function buildChatPrefill(el) {
|
|
172
|
-
if (!el) return "";
|
|
173
|
-
const moduleEl = el.closest("[data-module]");
|
|
174
|
-
const moduleName = moduleEl ? moduleEl.getAttribute("data-module") : null;
|
|
175
|
-
const tag = (el.tagName || "").toLowerCase();
|
|
176
|
-
const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
|
|
177
|
-
|
|
178
|
-
let elementPart;
|
|
179
|
-
if (tag.match(/^h[1-6]$/)) elementPart = "the headline";
|
|
180
|
-
else if (tag === "p") elementPart = "the paragraph";
|
|
181
|
-
else if (tag === "a") elementPart = "the link";
|
|
182
|
-
else if (tag === "button") elementPart = "the button";
|
|
183
|
-
else if (tag === "img") elementPart = "the image";
|
|
184
|
-
else elementPart = `the ${tag}`;
|
|
185
|
-
|
|
186
|
-
if (moduleEl === el && moduleName) {
|
|
187
|
-
return `In the ${moduleName} module, `;
|
|
188
|
-
}
|
|
189
|
-
if (moduleName) {
|
|
190
|
-
const quote = text ? ` ("${text}${text.length === 80 ? "…" : ""}")` : "";
|
|
191
|
-
return `In the ${moduleName} module, ${elementPart}${quote} `;
|
|
192
|
-
}
|
|
193
|
-
return `${elementPart} `;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function attachInteractHandlers() {
|
|
197
|
-
let doc;
|
|
198
|
-
try {
|
|
199
|
-
doc = previewFrame.contentDocument || previewFrame.contentWindow?.document;
|
|
200
|
-
} catch { return; }
|
|
201
|
-
if (!doc || !doc.body) return;
|
|
202
|
-
|
|
203
|
-
ensureInteractStyles(doc);
|
|
204
|
-
doc.documentElement.classList.add("vibespot-interact-mode");
|
|
205
|
-
|
|
206
|
-
let hoveredEl = null;
|
|
207
|
-
let labelEl = null;
|
|
208
|
-
let activeEditor = null;
|
|
209
|
-
|
|
210
|
-
const cleanup = () => {
|
|
211
|
-
if (hoveredEl) {
|
|
212
|
-
hoveredEl.classList.remove("vibespot-editable-hover", "vibespot-select-hover");
|
|
213
|
-
hoveredEl.removeAttribute("data-edit-type");
|
|
214
|
-
hoveredEl = null;
|
|
215
|
-
}
|
|
216
|
-
if (labelEl && labelEl.parentNode) {
|
|
217
|
-
labelEl.parentNode.removeChild(labelEl);
|
|
218
|
-
}
|
|
219
|
-
labelEl = null;
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const onMouseOver = (e) => {
|
|
223
|
-
if (activeEditor) return;
|
|
224
|
-
const info = getInteractInfo(e.target);
|
|
225
|
-
if (!info) {
|
|
226
|
-
cleanup();
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
if (hoveredEl === info.el) return;
|
|
230
|
-
cleanup();
|
|
231
|
-
hoveredEl = info.el;
|
|
232
|
-
hoveredEl.classList.add("vibespot-editable-hover");
|
|
233
|
-
hoveredEl.setAttribute("data-edit-type", info.action === "select" ? "select" : info.type);
|
|
234
|
-
|
|
235
|
-
if (!labelEl) {
|
|
236
|
-
labelEl = doc.createElement("div");
|
|
237
|
-
labelEl.className = "vibespot-edit-label";
|
|
238
|
-
doc.body.appendChild(labelEl);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (info.action === "select") {
|
|
242
|
-
labelEl.className = "vibespot-edit-label vibespot-edit-label--select";
|
|
243
|
-
labelEl.textContent = describeElement(info.el);
|
|
244
|
-
} else {
|
|
245
|
-
labelEl.className = "vibespot-edit-label vibespot-edit-label--edit";
|
|
246
|
-
const typeLabel = info.type === "image" ? "Click to edit image" : info.type === "link" ? "Click to edit link" : "Click to edit";
|
|
247
|
-
labelEl.textContent = typeLabel;
|
|
248
|
-
}
|
|
249
|
-
const rect = info.el.getBoundingClientRect();
|
|
250
|
-
labelEl.style.top = Math.max(4, rect.top - 20) + "px";
|
|
251
|
-
labelEl.style.left = Math.max(4, rect.left) + "px";
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const onMouseOut = (e) => {
|
|
255
|
-
if (activeEditor) return;
|
|
256
|
-
if (!e.relatedTarget) cleanup();
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
const closeActiveEditor = () => {
|
|
260
|
-
if (!activeEditor) return;
|
|
261
|
-
if (activeEditor.cleanup) activeEditor.cleanup();
|
|
262
|
-
activeEditor = null;
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
const onClick = (e) => {
|
|
266
|
-
e.preventDefault();
|
|
267
|
-
e.stopPropagation();
|
|
268
|
-
|
|
269
|
-
const info = getInteractInfo(e.target);
|
|
270
|
-
if (!info) return;
|
|
271
|
-
|
|
272
|
-
closeActiveEditor();
|
|
273
|
-
cleanup();
|
|
274
|
-
|
|
275
|
-
if (info.action === "select") {
|
|
276
|
-
const prefill = buildChatPrefill(info.el);
|
|
277
|
-
if (typeof window.prefillChatInput === "function") {
|
|
278
|
-
window.prefillChatInput(prefill);
|
|
279
|
-
}
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const moduleEl = info.el.closest("[data-module]");
|
|
284
|
-
if (!moduleEl) return;
|
|
285
|
-
const moduleName = moduleEl.getAttribute("data-module");
|
|
286
|
-
|
|
287
|
-
if (info.type === "text") {
|
|
288
|
-
startTextEdit(doc, info.el, moduleName);
|
|
289
|
-
} else if (info.type === "image") {
|
|
290
|
-
startImageEdit(doc, info.el, moduleName);
|
|
291
|
-
} else if (info.type === "link") {
|
|
292
|
-
startLinkEdit(doc, info.el, moduleName);
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
function startTextEdit(doc, el, moduleName) {
|
|
297
|
-
const originalText = el.textContent;
|
|
298
|
-
el.setAttribute("data-original-text", originalText.trim());
|
|
299
|
-
el.setAttribute("contenteditable", "true");
|
|
300
|
-
el.classList.add("vibespot-editing");
|
|
301
|
-
el.focus();
|
|
302
|
-
|
|
303
|
-
const range = doc.createRange();
|
|
304
|
-
range.selectNodeContents(el);
|
|
305
|
-
const sel = doc.defaultView.getSelection();
|
|
306
|
-
sel.removeAllRanges();
|
|
307
|
-
sel.addRange(range);
|
|
308
|
-
|
|
309
|
-
const save = () => {
|
|
310
|
-
el.removeAttribute("contenteditable");
|
|
311
|
-
el.classList.remove("vibespot-editing");
|
|
312
|
-
const newText = el.textContent.trim();
|
|
313
|
-
if (newText !== originalText.trim()) {
|
|
314
|
-
saveTextChange(moduleName, el, newText);
|
|
315
|
-
}
|
|
316
|
-
activeEditor = null;
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
el.addEventListener("blur", save, { once: true });
|
|
320
|
-
el.addEventListener("keydown", (e) => {
|
|
321
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
322
|
-
e.preventDefault();
|
|
323
|
-
el.blur();
|
|
324
|
-
}
|
|
325
|
-
if (e.key === "Escape") {
|
|
326
|
-
el.textContent = originalText;
|
|
327
|
-
el.blur();
|
|
328
|
-
}
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
activeEditor = {
|
|
332
|
-
cleanup: () => {
|
|
333
|
-
el.removeAttribute("contenteditable");
|
|
334
|
-
el.classList.remove("vibespot-editing");
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function startImageEdit(doc, imgEl, moduleName) {
|
|
340
|
-
const rect = imgEl.getBoundingClientRect();
|
|
341
|
-
const input = doc.createElement("input");
|
|
342
|
-
input.className = "vibespot-image-edit-input";
|
|
343
|
-
input.type = "text";
|
|
344
|
-
input.placeholder = "Enter image URL...";
|
|
345
|
-
input.value = imgEl.src || "";
|
|
346
|
-
input.style.top = (rect.bottom + 4) + "px";
|
|
347
|
-
input.style.left = Math.max(4, rect.left) + "px";
|
|
348
|
-
doc.body.appendChild(input);
|
|
349
|
-
input.focus();
|
|
350
|
-
input.select();
|
|
351
|
-
|
|
352
|
-
const save = () => {
|
|
353
|
-
const newSrc = input.value.trim();
|
|
354
|
-
if (input.parentNode) input.parentNode.removeChild(input);
|
|
355
|
-
if (newSrc && newSrc !== imgEl.src) {
|
|
356
|
-
imgEl.src = newSrc;
|
|
357
|
-
saveImageChange(moduleName, imgEl, newSrc);
|
|
358
|
-
}
|
|
359
|
-
activeEditor = null;
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
input.addEventListener("blur", save, { once: true });
|
|
363
|
-
input.addEventListener("keydown", (e) => {
|
|
364
|
-
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
|
|
365
|
-
if (e.key === "Escape") {
|
|
366
|
-
if (input.parentNode) input.parentNode.removeChild(input);
|
|
367
|
-
activeEditor = null;
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
activeEditor = {
|
|
372
|
-
cleanup: () => { if (input.parentNode) input.parentNode.removeChild(input); },
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function startLinkEdit(doc, anchorEl, moduleName) {
|
|
377
|
-
const origText = (anchorEl.textContent || "").trim();
|
|
378
|
-
const origHref = anchorEl.href || "";
|
|
379
|
-
const rect = anchorEl.getBoundingClientRect();
|
|
380
|
-
|
|
381
|
-
const popup = doc.createElement("div");
|
|
382
|
-
popup.className = "vibespot-link-edit-popup";
|
|
383
|
-
popup.style.top = (rect.bottom + 6) + "px";
|
|
384
|
-
popup.style.left = Math.max(4, rect.left) + "px";
|
|
385
|
-
|
|
386
|
-
const textLabel = doc.createElement("label");
|
|
387
|
-
textLabel.textContent = "Link text";
|
|
388
|
-
const textInput = doc.createElement("input");
|
|
389
|
-
textInput.type = "text";
|
|
390
|
-
textInput.className = "vibespot-link-text";
|
|
391
|
-
textInput.value = origText;
|
|
392
|
-
|
|
393
|
-
const urlLabel = doc.createElement("label");
|
|
394
|
-
urlLabel.textContent = "URL";
|
|
395
|
-
const urlInput = doc.createElement("input");
|
|
396
|
-
urlInput.type = "text";
|
|
397
|
-
urlInput.className = "vibespot-link-url";
|
|
398
|
-
urlInput.value = origHref;
|
|
399
|
-
|
|
400
|
-
const actions = doc.createElement("div");
|
|
401
|
-
actions.className = "vibespot-link-edit-actions";
|
|
402
|
-
const cancelBtn = doc.createElement("button");
|
|
403
|
-
cancelBtn.className = "vibespot-btn-cancel";
|
|
404
|
-
cancelBtn.textContent = "Cancel";
|
|
405
|
-
const saveBtn = doc.createElement("button");
|
|
406
|
-
saveBtn.className = "vibespot-btn-save";
|
|
407
|
-
saveBtn.textContent = "Save";
|
|
408
|
-
actions.appendChild(cancelBtn);
|
|
409
|
-
actions.appendChild(saveBtn);
|
|
410
|
-
|
|
411
|
-
popup.appendChild(textLabel);
|
|
412
|
-
popup.appendChild(textInput);
|
|
413
|
-
popup.appendChild(urlLabel);
|
|
414
|
-
popup.appendChild(urlInput);
|
|
415
|
-
popup.appendChild(actions);
|
|
416
|
-
doc.body.appendChild(popup);
|
|
417
|
-
textInput.focus();
|
|
418
|
-
|
|
419
|
-
const close = () => {
|
|
420
|
-
if (popup.parentNode) popup.parentNode.removeChild(popup);
|
|
421
|
-
activeEditor = null;
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
cancelBtn.addEventListener("click", close);
|
|
425
|
-
saveBtn.addEventListener("click", () => {
|
|
426
|
-
const newText = textInput.value.trim();
|
|
427
|
-
const newUrl = urlInput.value.trim();
|
|
428
|
-
if (newText) anchorEl.textContent = newText;
|
|
429
|
-
if (newUrl) anchorEl.href = newUrl;
|
|
430
|
-
if (newText !== origText || newUrl !== origHref) {
|
|
431
|
-
saveLinkChange(moduleName, anchorEl, newText, newUrl);
|
|
432
|
-
}
|
|
433
|
-
close();
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
popup.addEventListener("keydown", (e) => {
|
|
437
|
-
if (e.key === "Escape") close();
|
|
438
|
-
if (e.key === "Enter") {
|
|
439
|
-
e.preventDefault();
|
|
440
|
-
saveBtn.click();
|
|
441
|
-
}
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
activeEditor = { cleanup: close };
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const onKeyDown = (e) => {
|
|
448
|
-
if (e.key === "Escape" && !activeEditor) deactivateInteractMode();
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
doc.addEventListener("mouseover", onMouseOver, true);
|
|
452
|
-
doc.addEventListener("mouseout", onMouseOut, true);
|
|
453
|
-
doc.addEventListener("click", onClick, true);
|
|
454
|
-
doc.addEventListener("keydown", onKeyDown, true);
|
|
455
|
-
|
|
456
|
-
interactHandlers = {
|
|
457
|
-
doc,
|
|
458
|
-
onMouseOver,
|
|
459
|
-
onMouseOut,
|
|
460
|
-
onClick,
|
|
461
|
-
onKeyDown,
|
|
462
|
-
cleanup,
|
|
463
|
-
closeActiveEditor,
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function detachInteractHandlers() {
|
|
468
|
-
if (!interactHandlers) return;
|
|
469
|
-
const { doc, onMouseOver, onMouseOut, onClick, onKeyDown, cleanup, closeActiveEditor } = interactHandlers;
|
|
470
|
-
try {
|
|
471
|
-
closeActiveEditor();
|
|
472
|
-
cleanup();
|
|
473
|
-
doc.documentElement.classList.remove("vibespot-interact-mode");
|
|
474
|
-
doc.removeEventListener("mouseover", onMouseOver, true);
|
|
475
|
-
doc.removeEventListener("mouseout", onMouseOut, true);
|
|
476
|
-
doc.removeEventListener("click", onClick, true);
|
|
477
|
-
doc.removeEventListener("keydown", onKeyDown, true);
|
|
478
|
-
} catch { /* cross-origin */ }
|
|
479
|
-
interactHandlers = null;
|
|
480
|
-
}
|
|
481
14
|
|
|
482
15
|
function activateInteractMode() {
|
|
483
16
|
if (interactModeActive) return;
|
|
@@ -485,7 +18,7 @@ function activateInteractMode() {
|
|
|
485
18
|
if (interactBtn) interactBtn.setAttribute("aria-pressed", "true");
|
|
486
19
|
const previewContainer = document.getElementById("preview-container");
|
|
487
20
|
if (previewContainer) previewContainer.classList.add("preview--interact-mode");
|
|
488
|
-
|
|
21
|
+
setPreviewMode("interact");
|
|
489
22
|
}
|
|
490
23
|
|
|
491
24
|
function deactivateInteractMode() {
|
|
@@ -494,7 +27,7 @@ function deactivateInteractMode() {
|
|
|
494
27
|
if (interactBtn) interactBtn.setAttribute("aria-pressed", "false");
|
|
495
28
|
const previewContainer = document.getElementById("preview-container");
|
|
496
29
|
if (previewContainer) previewContainer.classList.remove("preview--interact-mode");
|
|
497
|
-
|
|
30
|
+
setPreviewMode("view");
|
|
498
31
|
}
|
|
499
32
|
|
|
500
33
|
function setInteractModeDisabled(disabled) {
|
|
@@ -515,18 +48,45 @@ if (interactBtn) {
|
|
|
515
48
|
});
|
|
516
49
|
}
|
|
517
50
|
|
|
518
|
-
if (typeof previewFrame !== "undefined" && previewFrame) {
|
|
519
|
-
previewFrame.addEventListener("load", () => {
|
|
520
|
-
if (interactModeActive) attachInteractHandlers();
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
51
|
// Backward-compat aliases
|
|
525
52
|
window.setEditModeDisabled = setInteractModeDisabled;
|
|
526
53
|
window.deactivateEditMode = deactivateInteractMode;
|
|
527
54
|
window.setSelectModeDisabled = setInteractModeDisabled;
|
|
528
55
|
window.deactivateSelectMode = deactivateInteractMode;
|
|
529
56
|
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Agent messages: module select -> chat prefill; edit commits -> field saves
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
// The agent may *request* dropping back to view mode (Esc in-frame with no
|
|
62
|
+
// editor open). Only the exit request is honoured — a compromised frame can
|
|
63
|
+
// lower its privileges, never raise them — and the toolbar flip re-issues
|
|
64
|
+
// vs:set-mode, so the parent stays authoritative over the mode.
|
|
65
|
+
onPreviewMessage("vs:request-mode", (p) => {
|
|
66
|
+
if (!p || p.mode !== "view") return;
|
|
67
|
+
deactivateInteractMode();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
onPreviewMessage("vs:select-module", (p) => {
|
|
71
|
+
if (typeof p.prefill !== "string") return;
|
|
72
|
+
if (typeof window.prefillChatInput === "function") {
|
|
73
|
+
// Preview content is AI-authored — cap the prefill so a hostile page
|
|
74
|
+
// can't stuff the composer.
|
|
75
|
+
window.prefillChatInput(p.prefill.slice(0, 400));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
onPreviewMessage("vs:edit-commit", (p) => {
|
|
80
|
+
if (!p || typeof p.moduleName !== "string" || !p.moduleName) return;
|
|
81
|
+
if (p.kind === "text" && typeof p.value === "string") {
|
|
82
|
+
saveTextChange(p.moduleName, p);
|
|
83
|
+
} else if (p.kind === "image" && typeof p.value === "string") {
|
|
84
|
+
saveImageChange(p.moduleName, p);
|
|
85
|
+
} else if (p.kind === "link") {
|
|
86
|
+
saveLinkChange(p.moduleName, p);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
530
90
|
// ---------------------------------------------------------------------------
|
|
531
91
|
// Save helpers — find matching field in fields.json and update via /api/field
|
|
532
92
|
// ---------------------------------------------------------------------------
|
|
@@ -560,40 +120,55 @@ function findFieldByText(fields, originalText, tag, prefix = "") {
|
|
|
560
120
|
return null;
|
|
561
121
|
}
|
|
562
122
|
|
|
563
|
-
function
|
|
123
|
+
function collectFieldsOfType(fields, type, prefix = "", out = []) {
|
|
564
124
|
for (const field of fields) {
|
|
565
125
|
const path = prefix ? `${prefix}.${field.name}` : field.name;
|
|
566
126
|
if (field.type === "group" && field.children) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
}
|
|
570
|
-
if (field.type === "image" && field.default?.src) {
|
|
571
|
-
return { path, field };
|
|
127
|
+
collectFieldsOfType(field.children, type, path, out);
|
|
128
|
+
continue;
|
|
572
129
|
}
|
|
130
|
+
if (field.type === type) out.push({ path, field });
|
|
573
131
|
}
|
|
574
|
-
return
|
|
132
|
+
return out;
|
|
575
133
|
}
|
|
576
134
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
return null;
|
|
135
|
+
// The agent reports the DOM's absolutized URL (possibly with a preview access
|
|
136
|
+
// token in the query) while field defaults are often relative — compare
|
|
137
|
+
// without query/hash and accept a suffix match either way.
|
|
138
|
+
function urlLikeMatches(a, b) {
|
|
139
|
+
a = String(a || "").split(/[?#]/)[0];
|
|
140
|
+
b = String(b || "").split(/[?#]/)[0];
|
|
141
|
+
if (!a || !b) return false;
|
|
142
|
+
return a === b || a.endsWith(b) || b.endsWith(a);
|
|
587
143
|
}
|
|
588
144
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
145
|
+
/** Match the edited image by its pre-edit src so editing the 2nd image can't
|
|
146
|
+
* overwrite the 1st (VIB-1898). Falls back to a lone image field only. */
|
|
147
|
+
function findImageField(fields, imgSrc) {
|
|
148
|
+
const candidates = collectFieldsOfType(fields, "image");
|
|
149
|
+
const bySrc = candidates.find((c) => urlLikeMatches(c.field.default?.src || "", imgSrc || ""));
|
|
150
|
+
if (bySrc) return bySrc;
|
|
151
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Same per-href matching for link fields (VIB-1898). */
|
|
155
|
+
function findLinkField(fields, href) {
|
|
156
|
+
const candidates = collectFieldsOfType(fields, "link");
|
|
157
|
+
const byHref = candidates.find((c) => urlLikeMatches(c.field.default?.url?.href || "", href || ""));
|
|
158
|
+
if (byHref) return byHref;
|
|
159
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function postField(moduleName, fieldPath, value) {
|
|
163
|
+
// Unified save path (field-save.js); the commit handlers refresh the
|
|
164
|
+
// preview once per commit rather than once per POST.
|
|
165
|
+
return saveField(moduleName, fieldPath, value, { refresh: false });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** p: { fieldPath?, originalText, tag, value } from vs:edit-commit. */
|
|
169
|
+
async function saveTextChange(moduleName, p) {
|
|
170
|
+
if (typeof p.fieldPath === "string" && p.fieldPath) {
|
|
171
|
+
await postField(moduleName, p.fieldPath, p.value);
|
|
597
172
|
refreshPreview();
|
|
598
173
|
return;
|
|
599
174
|
}
|
|
@@ -601,30 +176,20 @@ async function saveTextChange(moduleName, el, newText) {
|
|
|
601
176
|
const data = await findModuleFields(moduleName);
|
|
602
177
|
if (!data) { refreshPreview(); return; }
|
|
603
178
|
|
|
604
|
-
const originalText =
|
|
605
|
-
const match = findFieldByText(data.fields, originalText,
|
|
606
|
-
|
|
179
|
+
const originalText = typeof p.originalText === "string" && p.originalText ? p.originalText : p.value;
|
|
180
|
+
const match = findFieldByText(data.fields, originalText, p.tag || "");
|
|
607
181
|
if (match) {
|
|
608
|
-
await
|
|
609
|
-
method: "POST",
|
|
610
|
-
headers: { "Content-Type": "application/json" },
|
|
611
|
-
body: JSON.stringify({ moduleName, fieldPath: match.path, value: newText }),
|
|
612
|
-
});
|
|
182
|
+
await postField(moduleName, match.path, p.value);
|
|
613
183
|
}
|
|
614
184
|
refreshPreview();
|
|
615
185
|
}
|
|
616
186
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (fieldPath) {
|
|
620
|
-
await
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
body: JSON.stringify({
|
|
624
|
-
moduleName,
|
|
625
|
-
fieldPath,
|
|
626
|
-
value: { src: newSrc, alt: imgEl.getAttribute("alt") || "" },
|
|
627
|
-
}),
|
|
187
|
+
/** p: { fieldPath?, alt?, oldSrc?, value } from vs:edit-commit. */
|
|
188
|
+
async function saveImageChange(moduleName, p) {
|
|
189
|
+
if (typeof p.fieldPath === "string" && p.fieldPath) {
|
|
190
|
+
await postField(moduleName, p.fieldPath, {
|
|
191
|
+
src: p.value,
|
|
192
|
+
alt: typeof p.alt === "string" ? p.alt : "",
|
|
628
193
|
});
|
|
629
194
|
refreshPreview();
|
|
630
195
|
return;
|
|
@@ -633,43 +198,33 @@ async function saveImageChange(moduleName, imgEl, newSrc) {
|
|
|
633
198
|
const data = await findModuleFields(moduleName);
|
|
634
199
|
if (!data) { refreshPreview(); return; }
|
|
635
200
|
|
|
636
|
-
const match = findImageField(data.fields,
|
|
201
|
+
const match = findImageField(data.fields, p.oldSrc || "");
|
|
637
202
|
if (match) {
|
|
638
|
-
await
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
body: JSON.stringify({
|
|
642
|
-
moduleName,
|
|
643
|
-
fieldPath: match.path,
|
|
644
|
-
value: { src: newSrc, alt: match.field.default?.alt || "" },
|
|
645
|
-
}),
|
|
203
|
+
await postField(moduleName, match.path, {
|
|
204
|
+
src: p.value,
|
|
205
|
+
alt: match.field.default?.alt || "",
|
|
646
206
|
});
|
|
647
207
|
}
|
|
648
208
|
refreshPreview();
|
|
649
209
|
}
|
|
650
210
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const
|
|
211
|
+
/** p: { linkField?, textField?, origText?, origHref?, newText?, newUrl? }. */
|
|
212
|
+
async function saveLinkChange(moduleName, p) {
|
|
213
|
+
const newText = typeof p.newText === "string" ? p.newText : "";
|
|
214
|
+
const newUrl = typeof p.newUrl === "string" ? p.newUrl : "";
|
|
215
|
+
const linkField = typeof p.linkField === "string" ? p.linkField : "";
|
|
216
|
+
const textField = typeof p.textField === "string" ? p.textField : "";
|
|
654
217
|
|
|
655
218
|
if (linkField || textField) {
|
|
656
219
|
if (linkField && newUrl) {
|
|
657
|
-
await
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
moduleName,
|
|
662
|
-
fieldPath: linkField,
|
|
663
|
-
value: { url: { href: newUrl, type: "EXTERNAL" }, open_in_new_tab: false, no_follow: false },
|
|
664
|
-
}),
|
|
220
|
+
await postField(moduleName, linkField, {
|
|
221
|
+
url: { href: newUrl, type: "EXTERNAL" },
|
|
222
|
+
open_in_new_tab: false,
|
|
223
|
+
no_follow: false,
|
|
665
224
|
});
|
|
666
225
|
}
|
|
667
226
|
if (textField && newText) {
|
|
668
|
-
await
|
|
669
|
-
method: "POST",
|
|
670
|
-
headers: { "Content-Type": "application/json" },
|
|
671
|
-
body: JSON.stringify({ moduleName, fieldPath: textField, value: newText }),
|
|
672
|
-
});
|
|
227
|
+
await postField(moduleName, textField, newText);
|
|
673
228
|
}
|
|
674
229
|
refreshPreview();
|
|
675
230
|
return;
|
|
@@ -678,31 +233,22 @@ async function saveLinkChange(moduleName, anchorEl, newText, newUrl) {
|
|
|
678
233
|
const data = await findModuleFields(moduleName);
|
|
679
234
|
if (!data) { refreshPreview(); return; }
|
|
680
235
|
|
|
681
|
-
|
|
236
|
+
// Only write the URL when the user actually changed it — a text-only edit
|
|
237
|
+
// must not blank the stored href (VIB-1898).
|
|
238
|
+
const match = newUrl && newUrl !== (p.origHref || "") ? findLinkField(data.fields, p.origHref || "") : null;
|
|
682
239
|
if (match) {
|
|
683
|
-
await
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
moduleName,
|
|
688
|
-
fieldPath: match.path,
|
|
689
|
-
value: {
|
|
690
|
-
url: { href: newUrl, type: "EXTERNAL" },
|
|
691
|
-
open_in_new_tab: match.field.default?.open_in_new_tab ?? false,
|
|
692
|
-
no_follow: match.field.default?.no_follow ?? false,
|
|
693
|
-
},
|
|
694
|
-
}),
|
|
240
|
+
await postField(moduleName, match.path, {
|
|
241
|
+
url: { href: newUrl, type: "EXTERNAL" },
|
|
242
|
+
open_in_new_tab: match.field.default?.open_in_new_tab ?? false,
|
|
243
|
+
no_follow: match.field.default?.no_follow ?? false,
|
|
695
244
|
});
|
|
696
245
|
}
|
|
697
246
|
|
|
698
247
|
if (newText) {
|
|
699
|
-
|
|
248
|
+
// Match the pre-edit text so the right field default is found.
|
|
249
|
+
const textMatch = findFieldByText(data.fields, p.origText || newText, "a");
|
|
700
250
|
if (textMatch) {
|
|
701
|
-
await
|
|
702
|
-
method: "POST",
|
|
703
|
-
headers: { "Content-Type": "application/json" },
|
|
704
|
-
body: JSON.stringify({ moduleName, fieldPath: textMatch.path, value: newText }),
|
|
705
|
-
});
|
|
251
|
+
await postField(moduleName, textMatch.path, newText);
|
|
706
252
|
}
|
|
707
253
|
}
|
|
708
254
|
|