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
+89 -100
View File
@@ -31,7 +31,6 @@ import {
} from './MindSpacePageFullscreenPreview';
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
import { mergeMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch';
type AccessMode = MindSpacePublishCheck['accessMode'];
@@ -97,8 +96,6 @@ export function MindSpacePageDetail({
onContextUpdate,
autoOpenPlaza = false,
refreshTrigger = 0,
incomingPagePatch = null,
onPagePatchHandled,
overlayChat = null,
onFullscreenPreviewChange,
}: {
@@ -113,8 +110,6 @@ export function MindSpacePageDetail({
}) => void;
autoOpenPlaza?: boolean;
refreshTrigger?: number;
incomingPagePatch?: { patch: MindSpacePagePatch; sourceMessageId: string } | null;
onPagePatchHandled?: (sourceMessageId: string) => void;
overlayChat?: MindSpacePageFullscreenPreviewChat | null;
onFullscreenPreviewChange?: (open: boolean) => void;
}) {
@@ -134,6 +129,8 @@ export function MindSpacePageDetail({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState(0);
const [previewContent, setPreviewContent] = useState('');
const [previewRefreshPending, setPreviewRefreshPending] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [changeHighlight, setChangeHighlight] = useState<MindSpacePreviewChangeHighlight | null>(
null,
@@ -162,8 +159,6 @@ export function MindSpacePageDetail({
const [plazaPost, setPlazaPost] = useState<PlazaPostBrief | null>(null);
const [pushToPlaza, setPushToPlaza] = useState(false);
const [fixNotice, setFixNotice] = useState<MindSpaceRedactionChange[] | null>(null);
const [patchNotice, setPatchNotice] = useState<string | null>(null);
const [patchBusy, setPatchBusy] = useState(false);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
@@ -192,6 +187,9 @@ export function MindSpacePageDetail({
summary: next.summary,
content: next.content ?? '',
});
setPreviewContent(next.content ?? '');
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
setTemplateId(next.templateId);
setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id));
setAccessMode(next.publication?.accessMode ?? 'public');
@@ -419,6 +417,34 @@ export function MindSpacePageDetail({
});
}, []);
const markPreviewRefreshPending = useCallback(() => {
setPreviewRefreshPending(true);
}, []);
const handleManualPreviewRefresh = useCallback((nextContent?: string) => {
const html = nextContent ?? content;
setPreviewContent(html);
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
}, [content]);
const syncPreviewAfterHistory = useCallback(
(snapshot: PageDraftSnapshot) => {
if (previewRefreshPending) return;
setPreviewContent(snapshot.content);
setPreviewKey((value) => value + 1);
},
[previewRefreshPending],
);
const handlePreviewContentChange = useCallback(
(html: string) => {
setPreviewContent(html);
replaceDraft({ ...draft, content: html }, { recordHistory: true });
},
[draft, replaceDraft],
);
const refreshFromServer = useCallback(async () => {
const currentPage = page;
if (!currentPage) return;
@@ -438,52 +464,12 @@ export function MindSpacePageDetail({
}
applyPageRecord(next, { recordHistory: true });
if (draftChanged) {
setPreviewKey((value) => value + 1);
markPreviewRefreshPending();
}
} catch (err) {
setError(err instanceof Error ? err.message : '页面刷新失败');
}
}, [applyPageRecord, content, page, pageId, summary, title, triggerChangeHighlight]);
const applyIncomingPatch = useCallback(
async (patch: MindSpacePagePatch) => {
if (!page) return;
const merged = mergeMindSpacePagePatch({ title, summary, content }, patch);
triggerChangeHighlight({ title, summary, content });
setPatchBusy(true);
setPatchNotice(null);
setError(null);
try {
const updated = await updateMindSpacePage(page.id, {
expectedVersion: page.versionNo,
title: merged.title,
summary: merged.summary,
content: merged.content,
templateId,
changeNote: 'Agent 对话修改',
});
applyPageRecord(updated, { recordHistory: true });
setPreviewKey((value) => value + 1);
setPatchNotice('Agent 修改已同步到预览');
} catch (err) {
applyPageRecord(
{
...page,
title: merged.title,
summary: merged.summary,
content: merged.content,
},
{ recordHistory: true },
);
setPreviewKey((value) => value + 1);
setPatchNotice('预览已更新(保存到服务器失败,请稍后手动保存)');
setError(err instanceof Error ? err.message : '保存 Agent 修改失败');
} finally {
setPatchBusy(false);
}
},
[applyPageRecord, content, page, summary, templateId, title, triggerChangeHighlight],
);
}, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]);
const applyOneClickFix = async () => {
if (!page) return;
@@ -519,24 +505,6 @@ export function MindSpacePageDetail({
void refreshFromServer();
}, [refreshTrigger, refreshFromServer]);
useEffect(() => {
if (!incomingPagePatch || !page || patchBusy) return;
let cancelled = false;
void applyIncomingPatch(incomingPagePatch.patch).finally(() => {
if (!cancelled) onPagePatchHandled?.(incomingPagePatch.sourceMessageId);
});
return () => {
cancelled = true;
};
}, [
incomingPagePatch?.sourceMessageId,
incomingPagePatch?.patch,
page?.id,
patchBusy,
applyIncomingPatch,
onPagePatchHandled,
]);
useEffect(() => {
if (autoOpenPlaza && page?.publication && !plazaOpen) {
void openPlazaPanel();
@@ -556,16 +524,24 @@ export function MindSpacePageDetail({
return;
}
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
if (redo()) event.preventDefault();
const next = redo();
if (next) {
syncPreviewAfterHistory(next);
event.preventDefault();
}
return;
}
if (event.key.toLowerCase() === 'z') {
if (undo()) event.preventDefault();
const previous = undo();
if (previous) {
syncPreviewAfterHistory(previous);
event.preventDefault();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [redo, undo]);
}, [redo, syncPreviewAfterHistory, undo]);
useEffect(() => {
if (!page || !onContextUpdate) return;
@@ -581,17 +557,23 @@ export function MindSpacePageDetail({
const closeFullscreenPreview = () => {
setFullscreenPreviewOpen(false);
onFullscreenPreviewChange?.(false);
setPreviewKey((value) => value + 1);
setPreviewRefreshPending(false);
};
const handlePreviewUndo = () => {
if (!undo()) return false;
setPreviewKey((value) => value + 1);
const previous = undo();
if (!previous) return false;
if (previewRefreshPending) return true;
handleManualPreviewRefresh(previous.content);
return true;
};
const handlePreviewRedo = () => {
if (!redo()) return false;
setPreviewKey((value) => value + 1);
const next = redo();
if (!next) return false;
if (previewRefreshPending) return true;
handleManualPreviewRefresh(next.content);
return true;
};
@@ -649,7 +631,6 @@ export function MindSpacePageDetail({
</div>
{error && !deletePreviewOpen && <div className="mindspace-security-note">{error}</div>}
{patchNotice ? <div className="mindspace-fix-notice">{patchNotice}</div> : null}
<MindSpaceModal
open={deletePreviewOpen}
@@ -1072,25 +1053,35 @@ export function MindSpacePageDetail({
<div className="mindspace-page-preview">
<div className="mindspace-page-preview-bar">
<span>{page.contentFormat === 'html' ? '页面预览' : '安全预览'}</span>
<span>{page.contentFormat === 'html' ? '页面预览编辑' : '安全预览'}</span>
<div className="mindspace-page-preview-actions">
{patchBusy ? <span className="mindspace-page-preview-sync">同步 Agent 修改…</span> : null}
<button type="button" onClick={() => undo()} disabled={!canUndo} title="撤销 (⌘Z)">
<button type="button" onClick={() => {
const previous = undo();
if (previous) syncPreviewAfterHistory(previous);
}} disabled={!canUndo} title="撤销 (⌘Z)">
撤销
</button>
<button type="button" onClick={() => redo()} disabled={!canRedo} title="重做 (⌘⇧Z)">
<button type="button" onClick={() => {
const next = redo();
if (next) syncPreviewAfterHistory(next);
}} disabled={!canRedo} title="重做 (⌘⇧Z)">
重做
</button>
<button
type="button"
onClick={openFullscreenPreview}
disabled={page.contentFormat !== 'html' || !content.trim()}
title={page.contentFormat === 'html' ? '全屏预览并对话改页' : '仅 HTML 页面支持全屏编辑'}
title={page.contentFormat === 'html' ? '全屏可视化编辑,可配合 Agent 改页' : '仅 HTML 页面支持全屏编辑'}
>
全屏预览
全屏预览编辑
</button>
<button type="button" onClick={() => setPreviewKey((value) => value + 1)}>
刷新
<button
type="button"
className={previewRefreshPending ? 'is-refresh-pending' : undefined}
onClick={handleManualPreviewRefresh}
title={previewRefreshPending ? '草稿已有新内容,点击刷新预览' : '将当前草稿同步到预览'}
>
刷新预览{previewRefreshPending ? ' · 有新修改' : ''}
</button>
</div>
</div>
@@ -1099,11 +1090,9 @@ export function MindSpacePageDetail({
pageId={page.id}
title={title}
summary={summary}
content={content}
content={previewContent}
reloadKey={previewKey}
onContentChange={(html) =>
replaceDraft({ ...draft, content: html }, { recordHistory: true })
}
onContentChange={handlePreviewContentChange}
/>
) : (
<MindSpacePageDraftPreviewFrame
@@ -1114,6 +1103,7 @@ export function MindSpacePageDetail({
templateId={templateId}
reloadKey={previewKey}
minHeight={680}
maxHeight={12000}
/>
)}
{page.contentFormat === 'html' && (
@@ -1123,7 +1113,13 @@ export function MindSpacePageDetail({
className="mindspace-page-content-input"
value={content}
rows={22}
onChange={(event) => updateDraft({ ...draft, content: event.target.value })}
onChange={(event) => {
const nextContent = event.target.value;
updateDraft({ ...draft, content: nextContent });
if (!previewRefreshPending) {
setPreviewContent(nextContent);
}
}}
/>
</details>
)}
@@ -1164,26 +1160,19 @@ export function MindSpacePageDetail({
pageTitle={title || page.title}
title={title}
summary={summary}
content={content}
content={previewContent}
templateId={templateId}
reloadKey={previewKey}
patchBusy={patchBusy}
changeHighlight={changeHighlight}
canUndo={canUndo}
canRedo={canRedo}
onUndo={handlePreviewUndo}
onRedo={handlePreviewRedo}
onClose={closeFullscreenPreview}
onRefresh={() => setPreviewKey((value) => value + 1)}
onAgentIdleSync={() => void refreshFromServer()}
chat={
overlayChat
? {
...overlayChat,
onPagePatch: (patch) => void applyIncomingPatch(patch),
}
: null
}
onRefresh={() => handleManualPreviewRefresh()}
refreshPending={previewRefreshPending}
onContentChange={handlePreviewContentChange}
chat={overlayChat}
/>
) : null}
</>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { fetchMindSpacePageDraftPreview } from '../api/client';
import { bindIframeDocumentHeightSync } from '../utils/iframeContentHeight';
import { applyPageChangeHighlight } from '../utils/mindspacePageChangeHighlight';
export type MindSpacePreviewChangeHighlight = {
@@ -15,8 +16,9 @@ export function MindSpacePageDraftPreviewFrame({
content,
templateId,
reloadKey = 0,
liveUpdate = true,
minHeight = 680,
maxHeight = 4800,
maxHeight = 12000,
className = 'mindspace-page-preview-frame',
changeHighlight = null,
}: {
@@ -26,6 +28,7 @@ export function MindSpacePageDraftPreviewFrame({
content: string;
templateId: string;
reloadKey?: number;
liveUpdate?: boolean;
minHeight?: number;
maxHeight?: number;
className?: string;
@@ -62,7 +65,9 @@ export function MindSpacePageDraftPreviewFrame({
}, hasRenderedRef.current ? 450 : 120);
return () => window.clearTimeout(timer);
}, [pageId, title, summary, content, templateId, reloadKey]);
}, liveUpdate
? [pageId, title, summary, content, templateId, reloadKey]
: [pageId, reloadKey, templateId]);
useEffect(() => {
setHeight(minHeight);
@@ -72,27 +77,16 @@ export function MindSpacePageDraftPreviewFrame({
const iframe = iframeRef.current;
if (!iframe || !srcdoc) return;
const syncHeight = () => {
try {
const doc = iframe.contentDocument;
if (!doc) return;
const body = doc.body;
const root = doc.documentElement;
const next = Math.ceil(
Math.max(
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
minHeight,
),
);
setHeight(Math.min(next, maxHeight));
const applyHeight = (nextHeight: number) => {
setHeight(nextHeight);
if (
changeHighlight &&
appliedHighlightRevisionRef.current !== changeHighlight.revision
) {
if (
changeHighlight &&
appliedHighlightRevisionRef.current !== changeHighlight.revision
) {
try {
const doc = iframe.contentDocument;
if (!doc) return;
appliedHighlightRevisionRef.current = changeHighlight.revision;
applyPageChangeHighlight(doc, {
previousTitle: changeHighlight.previousTitle,
@@ -100,14 +94,17 @@ export function MindSpacePageDraftPreviewFrame({
previousContent: changeHighlight.previousContent,
nextContent: content,
});
} catch {
// ignore highlight errors in sandboxed preview
}
} catch {
setHeight(minHeight);
}
};
iframe.addEventListener('load', syncHeight);
return () => iframe.removeEventListener('load', syncHeight);
return bindIframeDocumentHeightSync(iframe, {
minHeight,
maxHeight,
onHeight: applyHeight,
});
}, [changeHighlight, content, maxHeight, minHeight, srcdoc, title]);
if (error) {
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import { bindIframeDocumentHeightSync, measureDocumentHeight } from '../utils/iframeContentHeight';
import {
buildEditablePreviewDocument,
MINDSPACE_PAGE_CONTENT_MESSAGE,
} from '../utils/mindspaceVisualEditor';
export function MindSpacePageEditablePreviewFrame({
title,
content,
reloadKey,
minHeight = 680,
maxHeight = 12000,
className = 'mindspace-page-preview-frame',
showHint = true,
onContentChange,
}: {
title: string;
content: string;
reloadKey: number;
minHeight?: number;
maxHeight?: number;
className?: string;
showHint?: boolean;
onContentChange: (html: string) => void;
}) {
const [previewFailed, setPreviewFailed] = useState(false);
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
const [height, setHeight] = useState(minHeight);
const iframeContentRef = useRef(content);
const iframeRef = useRef<HTMLIFrameElement>(null);
const syncHeightRef = useRef<(() => void) | null>(null);
useEffect(() => {
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
setPreviewFailed(false);
setHeight(minHeight);
}, [reloadKey]);
useEffect(() => {
if (content === iframeContentRef.current) return;
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
setPreviewFailed(false);
setHeight(minHeight);
}, [content, minHeight]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return;
const html = String(event.data.html ?? '');
if (!html.trim()) return;
iframeContentRef.current = html;
onContentChange(html);
syncHeightRef.current?.();
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [onContentChange]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !srcdoc) return;
const applyHeight = (nextHeight: number) => setHeight(nextHeight);
const unbind = bindIframeDocumentHeightSync(iframe, {
minHeight,
maxHeight,
onHeight: applyHeight,
});
syncHeightRef.current = () => {
try {
const doc = iframe.contentDocument;
if (!doc) return;
applyHeight(Math.min(measureDocumentHeight(doc, minHeight), maxHeight));
} catch {
applyHeight(minHeight);
}
};
return () => {
syncHeightRef.current = null;
unbind();
};
}, [maxHeight, minHeight, srcdoc]);
if (previewFailed) {
return <div className="mindspace-state mindspace-error">页面预览加载失败</div>;
}
return (
<>
{showHint ? (
<p className="mindspace-page-editable-hint">
文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址
</p>
) : null}
<iframe
ref={iframeRef}
title={`${title || '页面'} 预览`}
srcDoc={srcdoc}
sandbox="allow-same-origin allow-scripts"
scrolling="no"
className={className}
style={{ height: `${height}px` }}
onError={() => setPreviewFailed(true)}
/>
</>
);
}
@@ -1,11 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { usePageEditSubChat } from '../hooks/usePageEditSubChat';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
import type { MindSpacePagePatch } from '../utils/mindspacePagePatch';
import {
MindSpacePageDraftPreviewFrame,
type MindSpacePreviewChangeHighlight,
} from './MindSpacePageDraftPreviewFrame';
import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame';
import type { MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
export type MindSpacePageFullscreenPreviewChat = {
@@ -20,7 +17,6 @@ export type MindSpacePageFullscreenPreviewChat = {
categoryCode?: MindSpaceSaveCategory;
}) => void;
onSubSessionChange?: (sessionId: string | null) => void;
onPagePatch?: (patch: MindSpacePagePatch) => void;
onForkUnavailable?: () => void;
};
@@ -28,19 +24,19 @@ export function MindSpacePageFullscreenPreview({
pageId,
pageTitle,
title,
summary,
summary: _summary,
content,
templateId,
templateId: _templateId,
reloadKey,
patchBusy = false,
changeHighlight = null,
changeHighlight: _changeHighlight = null,
canUndo = false,
canRedo = false,
onUndo,
onRedo,
onClose,
onRefresh,
onAgentIdleSync,
refreshPending = false,
onContentChange,
chat,
}: {
pageId: string;
@@ -50,7 +46,6 @@ export function MindSpacePageFullscreenPreview({
content: string;
templateId: string;
reloadKey: number;
patchBusy?: boolean;
changeHighlight?: MindSpacePreviewChangeHighlight | null;
canUndo?: boolean;
canRedo?: boolean;
@@ -58,7 +53,8 @@ export function MindSpacePageFullscreenPreview({
onRedo?: () => boolean;
onClose: () => void;
onRefresh: () => void;
onAgentIdleSync?: () => void;
refreshPending?: boolean;
onContentChange: (html: string) => void;
chat?: MindSpacePageFullscreenPreviewChat | null;
}) {
const [chatOpen, setChatOpen] = useState(true);
@@ -69,8 +65,6 @@ export function MindSpacePageFullscreenPreview({
setSubChatFallback(false);
}, [pageId, chat?.parentSessionId]);
const prevSubChatStateRef = useRef(subChatEnabled ? 'loading' : 'idle');
const subChat = usePageEditSubChat({
pageId,
pageTitle: pageTitle || title,
@@ -79,7 +73,6 @@ export function MindSpacePageFullscreenPreview({
user: chat?.user,
enabled: subChatEnabled && !subChatFallback,
onSessionChange: chat?.onSubSessionChange,
onPagePatch: chat?.onPagePatch,
onForkUnavailable: () => {
setSubChatFallback(true);
chat?.onForkUnavailable?.();
@@ -133,27 +126,17 @@ export function MindSpacePageFullscreenPreview({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canRedo, canUndo, chatOpen, onClose, onRedo, onUndo]);
useEffect(() => {
if (!onAgentIdleSync || !subChatEnabled) return;
const previous = prevSubChatStateRef.current;
prevSubChatStateRef.current = subChat.chatState;
const wasBusy = previous === 'streaming' || previous === 'waiting';
if (wasBusy && subChat.chatState === 'idle') {
onAgentIdleSync();
}
}, [onAgentIdleSync, subChat.chatState, subChatEnabled]);
return (
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览">
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览编辑">
<header className="mindspace-page-fullscreen-preview-toolbar">
<div className="mindspace-page-fullscreen-preview-title">
<span className="mindspace-eyebrow">PREVIEW · EDIT</span>
<strong>{pageTitle || '页面预览'}</strong>
<span className="mindspace-eyebrow">VISUAL EDIT</span>
<strong>{pageTitle || '全屏预览编辑'}</strong>
<span className="mindspace-page-fullscreen-preview-hint">
文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址
</span>
</div>
<div className="mindspace-page-fullscreen-preview-actions">
{patchBusy ? (
<span className="mindspace-page-preview-sync">同步 Agent 修改…</span>
) : null}
{subChatEnabled && !subChatFallback && subChat.chatState === 'loading' ? (
<span className="mindspace-page-preview-sync">启动编辑 Agent…</span>
) : null}
@@ -166,27 +149,30 @@ export function MindSpacePageFullscreenPreview({
<button type="button" onClick={onRedo} disabled={!canRedo} title="重做 (⌘⇧Z)">
重做
</button>
<button type="button" onClick={onRefresh}>
刷新
<button
type="button"
className={refreshPending ? 'is-refresh-pending' : undefined}
onClick={onRefresh}
title={refreshPending ? '草稿已有新内容,点击刷新预览' : '将当前草稿同步到预览'}
>
刷新预览{refreshPending ? ' · 有新修改' : ''}
</button>
<button type="button" className="mindspace-primary" onClick={onClose}>
退出预览
退出编辑
</button>
</div>
</header>
<div className="mindspace-page-fullscreen-preview-body">
<MindSpacePageDraftPreviewFrame
pageId={pageId}
<MindSpacePageEditablePreviewFrame
title={title}
summary={summary}
content={content}
templateId={templateId}
reloadKey={reloadKey}
changeHighlight={changeHighlight}
minHeight={720}
maxHeight={12000}
className="mindspace-page-fullscreen-preview-frame"
showHint={false}
onContentChange={onContentChange}
/>
</div>
+11 -53
View File
@@ -3,10 +3,7 @@ import {
regenerateMindSpacePageThumbnail,
uploadMindSpacePageThumbnail,
} from '../api/client';
import {
buildEditablePreviewDocument,
MINDSPACE_PAGE_CONTENT_MESSAGE,
} from '../utils/mindspaceVisualEditor';
import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame';
function readFileAsBase64(file: File): Promise<{ base64: string; mimeType: string }> {
return new Promise((resolve, reject) => {
@@ -40,15 +37,11 @@ export function MindSpacePagePreviewPanel({
reloadKey: number;
onContentChange: (html: string) => void;
}) {
const [previewFailed, setPreviewFailed] = useState(false);
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(reloadKey);
const [thumbBusy, setThumbBusy] = useState<'upload' | 'ai' | null>(null);
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
const [thumbError, setThumbError] = useState<string | null>(null);
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
const iframeContentRef = useRef(content);
const iframeRef = useRef<HTMLIFrameElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
@@ -61,33 +54,6 @@ export function MindSpacePagePreviewPanel({
setThumbnailFailed(false);
}, [thumbnailUrl]);
useEffect(() => {
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
setPreviewFailed(false);
}, [reloadKey]);
useEffect(() => {
if (content === iframeContentRef.current) return;
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
setPreviewFailed(false);
}, [content]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return;
const html = String(event.data.html ?? '');
if (!html.trim()) return;
iframeContentRef.current = html;
onContentChange(html);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [onContentChange]);
const bumpThumbnail = () => {
setThumbnailVersion(Date.now());
setThumbnailFailed(false);
@@ -139,7 +105,6 @@ export function MindSpacePagePreviewPanel({
instruction: '根据当前页面内容与标题,生成更精美、可在信息流中直接展示的中文封面。',
});
if (result.content) {
iframeContentRef.current = result.content;
onContentChange(result.content);
}
bumpThumbnail();
@@ -154,25 +119,18 @@ export function MindSpacePagePreviewPanel({
return (
<div className="page-save-preview-dual mindspace-page-preview-dual">
<div className="page-save-preview-pane page-save-preview-pane-page">
<span className="page-save-preview-label">页面效果</span>
<span className="page-save-preview-label">页面预览编辑</span>
<p className="page-save-preview-hint">
点击文字直接编辑 · 点击图片替换 · 点击空白背景区域替换背景
文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址
</p>
<div className="page-save-mini-page mindspace-page-mini-page">
{!previewFailed ? (
<iframe
ref={iframeRef}
title="页面预览"
srcDoc={srcdoc}
className="page-save-mini-page-frame"
sandbox="allow-same-origin allow-scripts"
scrolling="yes"
onError={() => setPreviewFailed(true)}
/>
) : (
<div className="page-save-card-thumb-placeholder">页面预览加载失败</div>
)}
</div>
<MindSpacePageEditablePreviewFrame
title={title}
content={content}
reloadKey={reloadKey}
showHint={false}
className="mindspace-page-preview-frame"
onContentChange={onContentChange}
/>
</div>
<div className="page-save-preview-pane page-save-preview-pane-thumb">
<span className="page-save-preview-label">卡片预览图</span>
-32
View File
@@ -35,12 +35,6 @@ import type {
PortalUser,
} from '../types';
import { buildMindSpaceChatContext } from '../utils/mindspaceChatContext';
import {
extractMindSpacePagePatch,
patchKey,
type MindSpacePagePatch,
} from '../utils/mindspacePagePatch';
import { getDisplayText } from '../utils/message';
import { resolveH5ApiBase } from '../utils/h5ApiBase';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
import {
@@ -204,14 +198,9 @@ export function MindSpaceView({
content: string;
} | null>(null);
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
const [incomingPagePatch, setIncomingPagePatch] = useState<{
patch: MindSpacePagePatch;
sourceMessageId: string;
} | null>(null);
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
const [pageEditSubSessionId, setPageEditSubSessionId] = useState<string | null>(null);
const [pageEditSubSessionActive, setPageEditSubSessionActive] = useState(false);
const appliedPatchKeysRef = useRef(new Set<string>());
const { chatState, messages, session } = useChat();
const prevChatStateRef = useRef(chatState);
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
@@ -228,12 +217,10 @@ export function MindSpaceView({
useEffect(() => {
if (!selectedPageId) {
setPageLiveContext(null);
setIncomingPagePatch(null);
setPageFullscreenPreviewOpen(false);
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
}
appliedPatchKeysRef.current.clear();
}, [selectedPageId]);
useEffect(() => {
@@ -248,23 +235,6 @@ export function MindSpaceView({
pageEditSubSessionActive,
]);
useEffect(() => {
if (!selectedPageId || previewMode || pageFullscreenPreviewOpen) return;
if (chatState !== 'idle') return;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message.role !== 'assistant') continue;
const patch = extractMindSpacePagePatch(getDisplayText(message));
if (!patch) continue;
const key = `${message.id}:${patchKey(patch)}`;
if (appliedPatchKeysRef.current.has(key)) return;
appliedPatchKeysRef.current.add(key);
setIncomingPagePatch({ patch, sourceMessageId: message.id });
return;
}
}, [chatState, messages, previewMode, selectedPageId, pageFullscreenPreviewOpen]);
const mindspaceChatContext = useMemo(() => {
if (!space) return null;
return buildMindSpaceChatContext({
@@ -1204,8 +1174,6 @@ export function MindSpaceView({
onContextUpdate={setPageLiveContext}
autoOpenPlaza={selectedPageAutoPlaza}
refreshTrigger={pageRefreshTrigger}
incomingPagePatch={incomingPagePatch}
onPagePatchHandled={() => setIncomingPagePatch(null)}
onFullscreenPreviewChange={(open) => {
setPageFullscreenPreviewOpen(open);
if (!open) {
+9 -28
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { bindIframeDocumentHeightSync } from '../utils/iframeContentHeight';
export function PagePreviewFrame({
src,
title,
reloadKey = 0,
minHeight = 680,
maxHeight = 4800,
maxHeight = 12000,
className = 'mindspace-page-preview-frame',
}: {
src: string;
@@ -18,29 +19,6 @@ export function PagePreviewFrame({
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(minHeight);
const syncHeight = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (!doc) return;
const body = doc.body;
const root = doc.documentElement;
const next = Math.ceil(
Math.max(
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
minHeight,
),
);
setHeight(Math.min(next, maxHeight));
} catch {
setHeight(minHeight);
}
}, [maxHeight, minHeight]);
useEffect(() => {
setHeight(minHeight);
}, [src, reloadKey, minHeight]);
@@ -48,9 +26,12 @@ export function PagePreviewFrame({
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
iframe.addEventListener('load', syncHeight);
return () => iframe.removeEventListener('load', syncHeight);
}, [src, reloadKey, syncHeight]);
return bindIframeDocumentHeightSync(iframe, {
minHeight,
maxHeight,
onHeight: setHeight,
});
}, [maxHeight, minHeight, src, reloadKey]);
return (
<iframe
+6 -6
View File
@@ -67,28 +67,28 @@ export function usePageDraftHistory(initial: PageDraftSnapshot) {
[flushDebounced, snapshot],
);
const undo = useCallback(() => {
const undo = useCallback((): PageDraftSnapshot | null => {
flushDebounced();
const previous = pastRef.current[pastRef.current.length - 1];
if (!previous) return false;
if (!previous) return null;
pastRef.current = pastRef.current.slice(0, -1);
futureRef.current = [snapshot, ...futureRef.current];
stableRef.current = previous;
setSnapshot(previous);
bump();
return true;
return previous;
}, [flushDebounced, snapshot]);
const redo = useCallback(() => {
const redo = useCallback((): PageDraftSnapshot | null => {
flushDebounced();
const next = futureRef.current[0];
if (!next) return false;
if (!next) return null;
futureRef.current = futureRef.current.slice(1);
pastRef.current = [...pastRef.current, snapshot];
stableRef.current = next;
setSnapshot(next);
bump();
return true;
return next;
}, [flushDebounced, snapshot]);
const reset = useCallback(
-21
View File
@@ -19,7 +19,6 @@ import {
getToolConfirmation,
pushMessage,
} from '../utils/message';
import { extractMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch';
function buildPageEditSummary(messages: Message[], pageTitle: string): string {
const lines = messages
@@ -45,7 +44,6 @@ export function usePageEditSubChat({
user,
enabled,
onSessionChange,
onPagePatch,
onForkUnavailable,
}: {
pageId: string;
@@ -55,7 +53,6 @@ export function usePageEditSubChat({
user?: PortalUser | null;
enabled: boolean;
onSessionChange?: (sessionId: string | null) => void;
onPagePatch?: (patch: MindSpacePagePatch) => void;
onForkUnavailable?: () => void;
}) {
const [session, setSession] = useState<Session | null>(null);
@@ -69,7 +66,6 @@ export function usePageEditSubChat({
const unsubscribeRef = useRef<(() => void) | null>(null);
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const appliedPatchKeysRef = useRef(new Set<string>());
const closingRef = useRef(false);
useEffect(() => {
@@ -230,7 +226,6 @@ export function usePageEditSubChat({
setChatState('idle');
setPendingTool(null);
activeRequestId.current = null;
appliedPatchKeysRef.current.clear();
closingRef.current = false;
}
},
@@ -327,22 +322,6 @@ export function usePageEditSubChat({
};
}, [close, enabled, pageId, parentSessionId, start]);
useEffect(() => {
if (!onPagePatch) return;
if (chatState !== 'idle') return;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message.role !== 'assistant') continue;
const patch = extractMindSpacePagePatch(getDisplayText(message));
if (!patch) continue;
const key = `${message.id ?? i}:${JSON.stringify(patch)}`;
if (appliedPatchKeysRef.current.has(key)) return;
appliedPatchKeysRef.current.add(key);
onPagePatch(patch);
return;
}
}, [chatState, messages, onPagePatch]);
return {
session,
messages,
+34 -5
View File
@@ -5430,7 +5430,7 @@ body,
}
.mindspace-page-preview {
overflow: hidden;
overflow: visible;
}
.mindspace-page-preview-dual {
@@ -5442,10 +5442,6 @@ body,
width: 180px;
}
.mindspace-page-preview-dual .mindspace-page-mini-page {
height: min(520px, 58vh);
}
.mindspace-page-preview-dual + .mindspace-html-source-editor {
margin-top: 12px;
}
@@ -5518,6 +5514,31 @@ body,
cursor: not-allowed;
}
.mindspace-page-preview-actions button.is-refresh-pending,
.mindspace-page-fullscreen-preview-actions button.is-refresh-pending {
color: #18211d;
background: #eeb04e;
box-shadow: 0 0 0 2px rgba(238, 176, 78, 0.35);
animation: mindspace-refresh-pulse 1.4s ease-in-out infinite;
}
@keyframes mindspace-refresh-pulse {
0%,
100% {
box-shadow: 0 0 0 2px rgba(238, 176, 78, 0.35);
}
50% {
box-shadow: 0 0 0 6px rgba(238, 176, 78, 0.18);
}
}
.mindspace-page-editable-hint {
margin: 0;
padding: 10px 18px 0;
color: #6d7771;
font-size: 12px;
}
.mindspace-page-preview-loading {
padding: 18px;
color: #6d7771;
@@ -6038,6 +6059,14 @@ body,
white-space: nowrap;
}
.mindspace-page-fullscreen-preview-hint {
display: block;
margin-top: 4px;
color: #6d7771;
font-size: 11px;
line-height: 1.4;
}
.mindspace-page-fullscreen-preview-actions {
display: flex;
align-items: center;
+104
View File
@@ -0,0 +1,104 @@
export function measureDocumentHeight(doc: Document, minHeight: number): number {
const body = doc.body;
const root = doc.documentElement;
let measured = Math.ceil(
Math.max(
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
minHeight,
),
);
if (body && measured <= minHeight + 40) {
const blocks = body.querySelectorAll('.hero, .section, .quote-section, .footer, main, article');
for (const block of blocks) {
const el = block as HTMLElement;
measured = Math.max(measured, el.offsetTop + el.offsetHeight);
}
const last = body.lastElementChild as HTMLElement | null;
if (last) {
measured = Math.max(measured, last.offsetTop + last.offsetHeight);
}
}
return measured;
}
export function clampIframeHeight(height: number, minHeight: number, maxHeight: number): number {
return Math.min(Math.max(height, minHeight), maxHeight);
}
export function bindIframeDocumentHeightSync(
iframe: HTMLIFrameElement,
{
minHeight,
maxHeight,
onHeight,
}: {
minHeight: number;
maxHeight: number;
onHeight: (height: number) => void;
},
): () => void {
const disposers: Array<() => void> = [];
const apply = () => {
try {
const doc = iframe.contentDocument;
if (!doc) return;
onHeight(clampIframeHeight(measureDocumentHeight(doc, minHeight), minHeight, maxHeight));
} catch {
onHeight(minHeight);
}
};
const observeDocument = () => {
const doc = iframe.contentDocument;
if (!doc?.body) return;
apply();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(apply);
observer.observe(doc.body);
if (doc.documentElement && doc.documentElement !== doc.body) {
observer.observe(doc.documentElement);
}
disposers.push(() => observer.disconnect());
}
doc.querySelectorAll('img').forEach((img) => {
if (img.complete) return;
const handle = () => apply();
img.addEventListener('load', handle);
img.addEventListener('error', handle);
disposers.push(() => {
img.removeEventListener('load', handle);
img.removeEventListener('error', handle);
});
});
for (const delay of [100, 400, 1200]) {
const timer = window.setTimeout(apply, delay);
disposers.push(() => window.clearTimeout(timer));
}
};
const onLoad = () => observeDocument();
iframe.addEventListener('load', onLoad);
disposers.push(() => iframe.removeEventListener('load', onLoad));
try {
if (iframe.contentDocument?.readyState === 'complete') {
observeDocument();
}
} catch {
// ignore cross-origin access errors
}
return () => {
disposers.forEach((dispose) => dispose());
};
}
+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();
});