Add MindSpace visual page editor and Plaza local Tunnel deployment.

In-preview HTML editing with undo/redo sync replaces Agent patch auto-save; Plaza runs on local Mac via Cloudflare Tunnel with embed height and CSP fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 22:57:22 -07:00
parent 6ee6fd64dd
commit 25d9c8c364
24 changed files with 1173 additions and 351 deletions
+225 -20
View File
@@ -10,15 +10,27 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
outline: 2px solid rgba(61, 139, 253, 0.95);
background: rgba(61, 139, 253, 0.06);
}
[data-mindspace-editing="true"] img {
[data-mindspace-editing="true"] img,
[data-mindspace-editing="true"] video,
[data-mindspace-editing="true"] audio,
[data-mindspace-editing="true"] iframe[data-mindspace-media-target="true"] {
cursor: pointer;
outline: 1px dashed transparent;
outline-offset: 2px;
transition: outline-color 0.15s ease;
}
[data-mindspace-editing="true"] img:hover {
[data-mindspace-editing="true"] img:hover,
[data-mindspace-editing="true"] video:hover,
[data-mindspace-editing="true"] audio:hover,
[data-mindspace-editing="true"] iframe[data-mindspace-media-target="true"]:hover {
outline-color: rgba(238, 176, 78, 0.95);
}
[data-mindspace-editing="true"] a[data-mindspace-link-target="true"] {
cursor: text;
}
[data-mindspace-editing="true"] a[data-mindspace-link-target="true"]:hover {
text-decoration: underline dotted rgba(238, 176, 78, 0.85);
}
[data-mindspace-editing="true"] [data-mindspace-bg-target="true"] {
cursor: copy;
}
@@ -28,7 +40,7 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
.mindspace-editor-popover {
position: fixed;
z-index: 2147483646;
width: min(280px, calc(100vw - 24px));
width: min(300px, calc(100vw - 24px));
padding: 12px;
border: 1px solid rgba(24, 33, 29, 0.14);
border-radius: 12px;
@@ -42,11 +54,22 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
font-size: 13px;
font-weight: 700;
}
.mindspace-editor-popover-hint {
margin: 0 0 10px;
color: #6d7771;
font-size: 11px;
}
.mindspace-editor-popover label {
display: grid;
gap: 4px;
margin-bottom: 8px;
}
.mindspace-editor-popover label[data-inline="true"] {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.mindspace-editor-popover input[type='url'],
.mindspace-editor-popover input[type='text'],
.mindspace-editor-popover input[type='color'],
@@ -62,6 +85,7 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
.mindspace-editor-popover-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.mindspace-editor-popover-actions button {
@@ -75,16 +99,34 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
color: #fffaf0;
background: #18211d;
}
.mindspace-editor-popover-actions button[data-action='cancel'] {
.mindspace-editor-popover-actions button[data-action='cancel'],
.mindspace-editor-popover-actions button[data-action='clear'] {
color: #52605a;
background: rgba(24, 33, 29, 0.08);
}
.mindspace-editor-guide {
position: fixed;
z-index: 2147483645;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
max-width: min(720px, calc(100vw - 24px));
padding: 8px 14px;
border-radius: 999px;
color: #52605a;
font: 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: rgba(255, 252, 244, 0.94);
box-shadow: 0 8px 28px rgba(24, 33, 29, 0.12);
pointer-events: none;
}
</style>`;
const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
(function () {
var MESSAGE = ${JSON.stringify(MINDSPACE_PAGE_CONTENT_MESSAGE)};
var TEXT_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,figcaption,blockquote,span,a,label,small,strong,em,.sub,.tagline';
var TEXT_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,figcaption,blockquote,span,a,label,small,strong,em,b,i,u,sub,sup,cite,time,address,dt,dd,button,.sub,.tagline,[class*="title"],[class*="subtitle"],[class*="desc"],[class*="text"]';
var BLOCK_CHILD_SELECTOR = 'div,section,article,header,footer,main,p,h1,h2,h3,h4,h5,h6,ul,ol,table,form,video,audio,iframe,svg,canvas';
var MEDIA_BLOCK_SELECTOR = 'img,video,iframe,svg,canvas,table,ul,ol,form';
var popover = null;
var emitTimer = null;
@@ -93,15 +135,22 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
emitTimer = window.setTimeout(emitChange, 350);
}
function escapeAttr(value) {
return String(value || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function stripEditorArtifacts(root) {
root.querySelectorAll('#mindspace-visual-editor-style,#mindspace-visual-editor-script,.mindspace-editor-popover').forEach(function (node) {
root.querySelectorAll('#mindspace-visual-editor-style,#mindspace-visual-editor-script,.mindspace-editor-popover,.mindspace-editor-guide').forEach(function (node) {
node.remove();
});
root.querySelectorAll('[contenteditable]').forEach(function (node) {
node.removeAttribute('contenteditable');
node.removeAttribute('spellcheck');
});
root.querySelectorAll('[data-mindspace-bg-target]').forEach(function (node) {
root.querySelectorAll('[data-mindspace-bg-target],[data-mindspace-link-target],[data-mindspace-media-target]').forEach(function (node) {
node.removeAttribute('data-mindspace-bg-target');
node.removeAttribute('data-mindspace-link-target');
node.removeAttribute('data-mindspace-media-target');
});
if (root.body) root.body.removeAttribute('data-mindspace-editing');
}
@@ -125,12 +174,13 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
popover = null;
}
function openPopover(title, fieldsHtml, onApply, anchor) {
function openPopover(title, fieldsHtml, onApply, anchor, hint) {
closePopover();
popover = document.createElement('div');
popover.className = 'mindspace-editor-popover';
popover.innerHTML =
'<p>' + title + '</p>' +
(hint ? '<p class="mindspace-editor-popover-hint">' + hint + '</p>' : '') +
fieldsHtml +
'<div class="mindspace-editor-popover-actions">' +
'<button type="button" data-action="cancel">取消</button>' +
@@ -148,6 +198,14 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
closePopover();
if (pending !== 'async') emitChange();
});
var clearBtn = popover.querySelector('[data-action="clear"]');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
var pending = onApply(popover, 'clear');
closePopover();
if (pending !== 'async') emitChange();
});
}
}
function readFileAsDataUrl(file, callback) {
@@ -159,11 +217,14 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
function openImageEditor(img) {
openPopover(
'替换图片',
'<label>图片链接<input type="url" data-role="url" value="' + (img.src.indexOf('data:') === 0 ? '' : img.src.replace(/"/g, '&quot;')) + '" placeholder="https://..." /></label>' +
'<label>图片链接<input type="url" data-role="url" value="' + (img.src.indexOf('data:') === 0 ? '' : escapeAttr(img.src)) + '" placeholder="https://..." /></label>' +
'<label>替代文字(Alt<input type="text" data-role="alt" value="' + escapeAttr(img.alt || '') + '" placeholder="图片描述" /></label>' +
'<label>本地图片<input type="file" accept="image/*" data-role="file" /></label>',
function (panel) {
var fileInput = panel.querySelector('[data-role="file"]');
var urlInput = panel.querySelector('[data-role="url"]');
var altInput = panel.querySelector('[data-role="alt"]');
img.alt = String(altInput.value || '');
if (fileInput.files && fileInput.files[0]) {
readFileAsDataUrl(fileInput.files[0], function (dataUrl) {
img.src = dataUrl;
@@ -178,6 +239,76 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
);
}
function openMediaEditor(el, label) {
var src = el.getAttribute('src') || '';
var poster = el.getAttribute('poster') || '';
openPopover(
label,
'<label>资源链接<input type="url" data-role="url" value="' + escapeAttr(src) + '" placeholder="https://..." /></label>' +
(el.tagName === 'VIDEO'
? '<label>封面图(Poster<input type="url" data-role="poster" value="' + escapeAttr(poster) + '" placeholder="可选" /></label>'
: '') +
'<label>本地文件<input type="file" data-role="file" accept="' + (el.tagName === 'AUDIO' ? 'audio/*' : 'video/*,image/*') + '" /></label>',
function (panel) {
var fileInput = panel.querySelector('[data-role="file"]');
var urlInput = panel.querySelector('[data-role="url"]');
var posterInput = panel.querySelector('[data-role="poster"]');
if (fileInput.files && fileInput.files[0]) {
readFileAsDataUrl(fileInput.files[0], function (dataUrl) {
el.setAttribute('src', dataUrl);
if (el.tagName === 'VIDEO' && fileInput.files[0].type.startsWith('image/')) {
el.setAttribute('poster', dataUrl);
}
emitChange();
});
return 'async';
}
var next = String(urlInput.value || '').trim();
if (next) el.setAttribute('src', next);
if (posterInput) {
var posterNext = String(posterInput.value || '').trim();
if (posterNext) el.setAttribute('poster', posterNext);
}
},
el,
);
}
function openIframeEditor(iframe) {
var src = iframe.getAttribute('src') || '';
openPopover(
'替换嵌入内容',
'<label>嵌入链接<input type="url" data-role="url" value="' + escapeAttr(src) + '" placeholder="https://..." /></label>',
function (panel) {
var next = String(panel.querySelector('[data-role="url"]').value || '').trim();
if (next) iframe.setAttribute('src', next);
},
iframe,
'适用于地图、视频嵌入等 iframe 内容',
);
}
function openLinkEditor(anchor) {
openPopover(
'编辑链接',
'<label>显示文字<input type="text" data-role="text" value="' + escapeAttr(anchor.textContent || '') + '" /></label>' +
'<label>链接地址<input type="url" data-role="href" value="' + escapeAttr(anchor.getAttribute('href') || '') + '" placeholder="https://..." /></label>' +
'<label data-inline="true"><input type="checkbox" data-role="blank" ' + (anchor.target === '_blank' ? 'checked' : '') + ' /> 新窗口打开</label>',
function (panel) {
var text = String(panel.querySelector('[data-role="text"]').value || '');
var href = String(panel.querySelector('[data-role="href"]').value || '').trim();
var blank = panel.querySelector('[data-role="blank"]').checked;
anchor.textContent = text;
if (href) anchor.setAttribute('href', href);
else anchor.removeAttribute('href');
if (blank) anchor.setAttribute('target', '_blank');
else anchor.removeAttribute('target');
},
anchor,
'单击链接可改文字,双击打开此面板改地址',
);
}
function rgbToHex(color) {
var match = String(color || '').match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i);
if (!match) return '#ffffff';
@@ -185,27 +316,41 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
return '#' + hex(match[1]) + hex(match[2]) + hex(match[3]);
}
function extractBackgroundUrl(style) {
var bg = style.backgroundImage;
if (!bg || bg === 'none') return '';
var match = bg.match(/url\\(["']?([^"')]+)["']?\\)/);
return match ? match[1] : '';
}
function openBackgroundEditor(el) {
var style = window.getComputedStyle(el);
var currentUrl = extractBackgroundUrl(style);
openPopover(
'替换背景',
'<label>背景颜色<input type="color" data-role="color" value="' + rgbToHex(style.backgroundColor) + '" /></label>' +
'<label>背景图片链接<input type="url" data-role="bg-url" placeholder="留空则仅使用颜色" /></label>' +
'<label>本地背景图<input type="file" accept="image/*" data-role="bg-file" /></label>',
function (panel) {
'<label>背景图片链接<input type="url" data-role="bg-url" value="' + escapeAttr(currentUrl) + '" placeholder="留空则仅使用颜色" /></label>' +
'<label>本地背景图<input type="file" accept="image/*" data-role="bg-file" /></label>' +
'<button type="button" data-action="clear">清除背景图</button>',
function (panel, mode) {
var color = panel.querySelector('[data-role="color"]').value;
var bgUrl = String(panel.querySelector('[data-role="bg-url"]').value || '').trim();
var bgFile = panel.querySelector('[data-role="bg-file"]');
function applyBackground(url) {
if (url) {
if (mode === 'clear' || !url) {
el.style.backgroundImage = 'none';
} else {
el.style.backgroundImage = 'url("' + url.replace(/"/g, '\\"') + '")';
el.style.backgroundSize = el.style.backgroundSize || 'cover';
el.style.backgroundPosition = el.style.backgroundPosition || 'center';
} else {
el.style.backgroundImage = 'none';
el.style.backgroundRepeat = el.style.backgroundRepeat || 'no-repeat';
}
if (color) el.style.backgroundColor = color;
}
if (mode === 'clear') {
applyBackground('');
return;
}
if (bgFile.files && bgFile.files[0]) {
readFileAsDataUrl(bgFile.files[0], function (url) {
applyBackground(url);
@@ -216,16 +361,17 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
applyBackground(bgUrl);
},
el,
'点击大面积空白或有色区块即可替换背景',
);
}
function markBackgroundTargets() {
var nodes = document.querySelectorAll('body, main, section, article, header, footer, div');
nodes.forEach(function (el) {
if (el.closest('.mindspace-editor-popover')) return;
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
var style = window.getComputedStyle(el);
var cls = String(el.className || '');
var structural = /\\b(page|hero|cover|banner|bg|background|section|card|panel|wrap|container)\\b/i.test(cls);
var structural = /\\b(page|hero|cover|banner|bg|background|section|card|panel|wrap|container|shell|stage|canvas|frame|viewport)\\b/i.test(cls);
var painted = style.backgroundImage !== 'none' || (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent');
if (el.tagName === 'BODY' || structural || (painted && el.offsetWidth > 64 && el.offsetHeight > 64)) {
el.setAttribute('data-mindspace-bg-target', 'true');
@@ -233,24 +379,66 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
});
}
function markMediaTargets() {
document.querySelectorAll('iframe').forEach(function (iframe) {
if (iframe.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
iframe.setAttribute('data-mindspace-media-target', 'true');
});
}
function markLinkTargets() {
document.querySelectorAll('a[href]').forEach(function (anchor) {
if (anchor.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
anchor.setAttribute('data-mindspace-link-target', 'true');
});
}
function enableTextEditing() {
document.querySelectorAll(TEXT_SELECTOR).forEach(function (el) {
if (el.closest('.mindspace-editor-popover')) return;
if (el.querySelector && el.querySelector('img,video,iframe,svg,canvas')) return;
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
if (el.querySelector && el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
el.setAttribute('contenteditable', 'true');
el.setAttribute('spellcheck', 'true');
});
document.querySelectorAll('div').forEach(function (el) {
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
if (el.hasAttribute('contenteditable')) return;
if (el.querySelector(BLOCK_CHILD_SELECTOR)) return;
if (el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
if (!String(el.textContent || '').trim()) return;
el.setAttribute('contenteditable', 'true');
el.setAttribute('spellcheck', 'true');
});
}
function addEditGuide() {
var bar = document.createElement('div');
bar.className = 'mindspace-editor-guide';
bar.textContent = '文字:直接点击 · 图片/音视频:点击替换 · 背景:点击空白区域 · 链接:双击改地址';
document.body.appendChild(bar);
}
function onClick(event) {
var target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('.mindspace-editor-popover')) return;
if (target.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
if (target.tagName === 'IMG') {
event.preventDefault();
openImageEditor(target);
return;
}
if (target.tagName === 'VIDEO' || target.tagName === 'AUDIO') {
event.preventDefault();
openMediaEditor(target, target.tagName === 'VIDEO' ? '替换视频' : '替换音频');
return;
}
if (target.tagName === 'IFRAME' && target.getAttribute('data-mindspace-media-target') === 'true') {
event.preventDefault();
openIframeEditor(target);
return;
}
if (target.isContentEditable) return;
if (target.matches('[data-mindspace-bg-target="true"]') || target.tagName === 'BODY') {
event.preventDefault();
@@ -258,13 +446,30 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
}
}
function onDblClick(event) {
var target = event.target;
if (!(target instanceof Element)) return;
var anchor = target.closest('a[data-mindspace-link-target="true"]');
if (!anchor) return;
event.preventDefault();
openLinkEditor(anchor);
}
function init() {
document.body.setAttribute('data-mindspace-editing', 'true');
enableTextEditing();
markBackgroundTargets();
markMediaTargets();
markLinkTargets();
addEditGuide();
document.addEventListener('input', debounceEmit);
document.addEventListener('blur', debounceEmit, true);
document.addEventListener('click', onClick, true);
document.addEventListener('dblclick', onDblClick, true);
document.addEventListener('click', function (event) {
var target = event.target;
if (target instanceof HTMLAnchorElement) event.preventDefault();
}, true);
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') closePopover();
});