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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user