Improve MindSpace visual editor UX and default RMB billing.

Add color-coded edit highlights with an in-preview legend, strip CSP meta
for srcdoc editing, and simplify fullscreen preview to immersive visual edit
only. Default billing to CNY token rates unless H5_USE_BACKEND_COST=1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 23:47:23 -07:00
parent 8d2f55771c
commit 79457230c1
11 changed files with 339 additions and 225 deletions
+5 -9
View File
@@ -27,7 +27,6 @@ import { resolvePlazaPostUrl, resolvePublicPageUrl } from '../utils/publicUrl';
import { MindSpacePageDraftPreviewFrame, type MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
import {
MindSpacePageFullscreenPreview,
type MindSpacePageFullscreenPreviewChat,
} from './MindSpacePageFullscreenPreview';
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
@@ -96,7 +95,6 @@ export function MindSpacePageDetail({
onContextUpdate,
autoOpenPlaza = false,
refreshTrigger = 0,
overlayChat = null,
onFullscreenPreviewChange,
}: {
pageId: string;
@@ -110,7 +108,6 @@ export function MindSpacePageDetail({
}) => void;
autoOpenPlaza?: boolean;
refreshTrigger?: number;
overlayChat?: MindSpacePageFullscreenPreviewChat | null;
onFullscreenPreviewChange?: (open: boolean) => void;
}) {
const [page, setPage] = useState<MindSpacePage | null>(null);
@@ -550,6 +547,9 @@ export function MindSpacePageDetail({
const openFullscreenPreview = () => {
if (!page || page.contentFormat !== 'html' || !content.trim()) return;
setPreviewContent(content);
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
setFullscreenPreviewOpen(true);
onFullscreenPreviewChange?.(true);
};
@@ -1071,7 +1071,7 @@ export function MindSpacePageDetail({
type="button"
onClick={openFullscreenPreview}
disabled={page.contentFormat !== 'html' || !content.trim()}
title={page.contentFormat === 'html' ? '全屏可视化编辑,可配合 Agent 改页' : '仅 HTML 页面支持全屏编辑'}
title={page.contentFormat === 'html' ? '进入沉浸式可视化编辑' : '仅 HTML 页面支持全屏编辑'}
>
</button>
@@ -1156,12 +1156,9 @@ export function MindSpacePageDetail({
{fullscreenPreviewOpen && page && page.contentFormat === 'html' ? (
<MindSpacePageFullscreenPreview
pageId={page.id}
pageTitle={title || page.title}
title={title}
summary={summary}
content={previewContent}
templateId={templateId}
content={content}
reloadKey={previewKey}
changeHighlight={changeHighlight}
canUndo={canUndo}
@@ -1172,7 +1169,6 @@ export function MindSpacePageDetail({
onRefresh={() => handleManualPreviewRefresh()}
refreshPending={previewRefreshPending}
onContentChange={handlePreviewContentChange}
chat={overlayChat}
/>
) : null}
</>
@@ -11,7 +11,9 @@ export function MindSpacePageEditablePreviewFrame({
reloadKey,
minHeight = 680,
maxHeight = 12000,
className = 'mindspace-page-preview-frame',
className,
compact = false,
fillViewport = false,
showHint = true,
onContentChange,
}: {
@@ -21,9 +23,14 @@ export function MindSpacePageEditablePreviewFrame({
minHeight?: number;
maxHeight?: number;
className?: string;
compact?: boolean;
fillViewport?: boolean;
showHint?: boolean;
onContentChange: (html: string) => void;
}) {
const scrollInsideFrame = compact || fillViewport;
const frameClassName =
className ?? (compact ? 'page-save-mini-page-frame' : 'mindspace-page-preview-frame');
const [previewFailed, setPreviewFailed] = useState(false);
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
const [height, setHeight] = useState(minHeight);
@@ -62,6 +69,11 @@ export function MindSpacePageEditablePreviewFrame({
}, [onContentChange]);
useEffect(() => {
if (scrollInsideFrame) {
syncHeightRef.current = null;
return;
}
const iframe = iframeRef.current;
if (!iframe || !srcdoc) return;
@@ -85,7 +97,7 @@ export function MindSpacePageEditablePreviewFrame({
syncHeightRef.current = null;
unbind();
};
}, [maxHeight, minHeight, srcdoc]);
}, [maxHeight, minHeight, scrollInsideFrame, srcdoc]);
if (previewFailed) {
return <div className="mindspace-state mindspace-error"></div>;
@@ -95,7 +107,14 @@ export function MindSpacePageEditablePreviewFrame({
<>
{showHint ? (
<p className="mindspace-page-editable-hint">
· / · ·
<span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-text" />
<span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-media" />
/
<span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-link" />
<span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-bg" />
线
</p>
) : null}
<iframe
@@ -103,9 +122,9 @@ export function MindSpacePageEditablePreviewFrame({
title={`${title || '页面'} 预览`}
srcDoc={srcdoc}
sandbox="allow-same-origin allow-scripts"
scrolling="no"
className={className}
style={{ height: `${height}px` }}
scrolling={scrollInsideFrame ? 'yes' : 'no'}
className={frameClassName}
style={scrollInsideFrame ? { height: '100%' } : { height: `${height}px` }}
onError={() => setPreviewFailed(true)}
/>
</>
@@ -1,32 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { usePageEditSubChat } from '../hooks/usePageEditSubChat';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
import { useEffect } from 'react';
import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame';
import type { MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
export type MindSpacePageFullscreenPreviewChat = {
context: MindSpaceChatContext;
user: PortalUser;
parentSessionId?: string | null;
h5ApiBase?: string | null;
onOpenFullChat: () => void;
onPageSaved?: (result: {
kind: 'page' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
onSubSessionChange?: (sessionId: string | null) => void;
onForkUnavailable?: () => void;
};
export function MindSpacePageFullscreenPreview({
pageId,
pageTitle,
title,
summary: _summary,
content,
templateId: _templateId,
reloadKey,
changeHighlight: _changeHighlight = null,
canUndo = false,
@@ -37,14 +16,10 @@ export function MindSpacePageFullscreenPreview({
onRefresh,
refreshPending = false,
onContentChange,
chat,
}: {
pageId: string;
pageTitle: string;
title: string;
summary: string;
content: string;
templateId: string;
reloadKey: number;
changeHighlight?: MindSpacePreviewChangeHighlight | null;
canUndo?: boolean;
@@ -55,40 +30,7 @@ export function MindSpacePageFullscreenPreview({
onRefresh: () => void;
refreshPending?: boolean;
onContentChange: (html: string) => void;
chat?: MindSpacePageFullscreenPreviewChat | null;
}) {
const [chatOpen, setChatOpen] = useState(true);
const [subChatFallback, setSubChatFallback] = useState(false);
const subChatEnabled = Boolean(chat?.parentSessionId);
useEffect(() => {
setSubChatFallback(false);
}, [pageId, chat?.parentSessionId]);
const subChat = usePageEditSubChat({
pageId,
pageTitle: pageTitle || title,
parentSessionId: chat?.parentSessionId,
h5ApiBase: chat?.h5ApiBase,
user: chat?.user,
enabled: subChatEnabled && !subChatFallback,
onSessionChange: chat?.onSubSessionChange,
onForkUnavailable: () => {
setSubChatFallback(true);
chat?.onForkUnavailable?.();
},
});
const subChatContext = useMemo(() => {
if (!chat?.context) return null;
return {
...chat.context,
pageEditMode: true,
parentAgentSessionId: chat.parentSessionId ?? chat.context.parentAgentSessionId,
agentSessionId: subChat.session?.id ?? chat.context.agentSessionId,
} satisfies MindSpaceChatContext;
}, [chat, subChat.session?.id]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
@@ -118,13 +60,13 @@ export function MindSpacePageFullscreenPreview({
return;
}
if (event.key !== 'Escape') return;
if (chatOpen) return;
onClose();
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canRedo, canUndo, chatOpen, onClose, onRedo, onUndo]);
}, [canRedo, canUndo, onClose, onRedo, onUndo]);
return (
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览编辑">
@@ -133,16 +75,10 @@ export function MindSpacePageFullscreenPreview({
<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">
{subChatEnabled && !subChatFallback && subChat.chatState === 'loading' ? (
<span className="mindspace-page-preview-sync"> Agent</span>
) : null}
{subChatFallback ? (
<span className="mindspace-page-preview-sync">使 Agent </span>
) : null}
<button type="button" onClick={onUndo} disabled={!canUndo} title="撤销 (⌘Z)">
</button>
@@ -168,28 +104,12 @@ export function MindSpacePageFullscreenPreview({
title={title}
content={content}
reloadKey={reloadKey}
minHeight={720}
maxHeight={12000}
fillViewport
className="mindspace-page-fullscreen-preview-frame"
showHint={false}
onContentChange={onContentChange}
/>
</div>
{chat && subChatContext ? (
<MindSpaceSpaceChat
context={subChatContext}
user={chat.user}
onOpenFullChat={chat.onOpenFullChat}
onPageSaved={chat.onPageSaved}
open={chatOpen}
onOpenChange={setChatOpen}
variant="overlay"
chatBridge={subChatEnabled && !subChatFallback ? subChat : undefined}
hideOpenFullChat={!subChatFallback}
title={subChatFallback ? 'TKMind' : '页面编辑 Agent'}
/>
) : null}
</div>
);
}
+15 -10
View File
@@ -118,19 +118,24 @@ 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">
<div
className="page-save-preview-pane page-save-preview-pane-page"
onWheelCapture={(event) => event.stopPropagation()}
>
<span className="page-save-preview-label"></span>
<p className="page-save-preview-hint">
· / · ·
· 线= · = · = · 绿=
</p>
<MindSpacePageEditablePreviewFrame
title={title}
content={content}
reloadKey={reloadKey}
showHint={false}
className="mindspace-page-preview-frame"
onContentChange={onContentChange}
/>
<div className="page-save-mini-page">
<MindSpacePageEditablePreviewFrame
title={title}
content={content}
reloadKey={reloadKey}
compact
showHint={false}
onContentChange={onContentChange}
/>
</div>
</div>
<div className="page-save-preview-pane page-save-preview-pane-thumb">
<span className="page-save-preview-label"></span>
+6 -61
View File
@@ -199,8 +199,6 @@ export function MindSpaceView({
} | null>(null);
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
const [pageEditSubSessionId, setPageEditSubSessionId] = useState<string | null>(null);
const [pageEditSubSessionActive, setPageEditSubSessionActive] = useState(false);
const { chatState, messages, session } = useChat();
const prevChatStateRef = useRef(chatState);
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
@@ -218,22 +216,13 @@ export function MindSpaceView({
if (!selectedPageId) {
setPageLiveContext(null);
setPageFullscreenPreviewOpen(false);
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
}
}, [selectedPageId]);
useEffect(() => {
if (!selectedPageId || !session?.id || previewMode) return;
if (pageFullscreenPreviewOpen && pageEditSubSessionActive) return;
if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return;
void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(() => {});
}, [
selectedPageId,
session?.id,
previewMode,
pageFullscreenPreviewOpen,
pageEditSubSessionActive,
]);
}, [selectedPageId, session?.id, previewMode, pageFullscreenPreviewOpen]);
const mindspaceChatContext = useMemo(() => {
if (!space) return null;
@@ -247,10 +236,8 @@ export function MindSpaceView({
assets,
focusedAsset: agentAsset,
route: `${location.pathname}${location.search}`,
agentSessionId: pageEditSubSessionId ?? session?.id ?? null,
agentSessionId: session?.id ?? null,
h5ApiBase,
pageEditMode: pageFullscreenPreviewOpen,
parentAgentSessionId: pageFullscreenPreviewOpen ? session?.id ?? null : null,
});
}, [
space,
@@ -265,8 +252,6 @@ export function MindSpaceView({
location.search,
session?.id,
h5ApiBase,
pageFullscreenPreviewOpen,
pageEditSubSessionId,
]);
const truncateLabel = (text: string, max = 48) => {
@@ -537,10 +522,10 @@ export function MindSpaceView({
const previous = prevChatStateRef.current;
prevChatStateRef.current = chatState;
const wasBusy = previous === 'streaming' || previous === 'waiting';
if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode) return;
if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode || pageFullscreenPreviewOpen) return;
setPageRefreshTrigger((value) => value + 1);
void refreshPagesSilently();
}, [chatState, selectedPageId, previewMode]);
}, [chatState, pageFullscreenPreviewOpen, previewMode, selectedPageId]);
useEffect(() => {
void load();
@@ -1174,47 +1159,7 @@ export function MindSpaceView({
onContextUpdate={setPageLiveContext}
autoOpenPlaza={selectedPageAutoPlaza}
refreshTrigger={pageRefreshTrigger}
onFullscreenPreviewChange={(open) => {
setPageFullscreenPreviewOpen(open);
if (!open) {
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
}
}}
overlayChat={
mindspaceChatContext && session?.id
? {
context: mindspaceChatContext,
user,
parentSessionId: session.id,
h5ApiBase,
onOpenFullChat: onBack,
onSubSessionChange: (subSessionId) => {
setPageEditSubSessionId(subSessionId);
setPageEditSubSessionActive(Boolean(subSessionId));
},
onForkUnavailable: () => {
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
if (selectedPageId && session?.id) {
void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(
() => {},
);
}
},
onPageSaved: (result) => {
if (result.kind === 'page' && result.pageId) {
void refreshPagesSilently();
if (result.pageId === selectedPageId) {
setPageRefreshTrigger((value) => value + 1);
} else {
showPage(result.pageId);
}
}
},
}
: null
}
onFullscreenPreviewChange={setPageFullscreenPreviewOpen}
/>
)
) : newPageOpen ? (