fix(creator-studio): 固定公众号式创作工具栏
- 顶部改为 fixed 创作工具栏:左侧返回/品牌/模式,中间文章标题与保存状态, 右侧撤销/重做、保存草稿、预览、发布 - 新增工作台级撤销/重做快照栈,连续编辑合并记录;支持 Ctrl/Cmd+Z / Shift+Z, 不覆盖 RichText 输入框自身的编辑历史 - 移动端收敛工具栏布局,保留撤销/重做、预览、发布 - 先保存草稿再请求审核/直接发布;请求使用服务端更新后的文章与版本 ID - 固定工具栏以独立 chrome 层布局,正文避开顶部工具栏高度 - 补充固定工具栏、历史操作与键盘快捷键契约 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f5e9420266
commit
9e8913ea0a
9 files changed
+518
-50
No files matched your search
@@ -27,6 +27,7 @@ import { InsertPanel, type InsertItem } from './panels/insert-panel';
|
||||
import { buildInsertGroups } from './utils/insert-items';
|
||||
import { SaveState, type SaveStateValue } from './components/save-state';
|
||||
import { useAutosave } from './hooks/use-autosave';
|
||||
import { useEditorHistory } from './hooks/use-editor-history';
|
||||
import { useLocalRecovery } from './hooks/use-local-recovery';
|
||||
import { normalizePayForRequest } from './utils/pay';
|
||||
import './styles/studio.scss';
|
||||
@@ -109,12 +110,55 @@ function initialDraft(): Draft {
|
||||
function Studio(): JSX.Element {
|
||||
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||
const [blocks, setBlocks] = useState(() => parse(draft.content || ''));
|
||||
const [saveState, setSaveState] = useState<SaveStateValue>({ state: 'ready', at: '' });
|
||||
const blocksRef = useRef(blocks);
|
||||
blocksRef.current = blocks;
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
|
||||
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
|
||||
at: '',
|
||||
}));
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [insertOpen, setInsertOpen] = useState(false);
|
||||
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
|
||||
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const restoreBlockSignatureRef = useRef('');
|
||||
|
||||
const restoreHistory = useCallback((value: { blocks: any[]; title: string; excerpt: string }) => {
|
||||
dirtyRef.current = true;
|
||||
blocksRef.current = value.blocks;
|
||||
restoreBlockSignatureRef.current = serialize(value.blocks);
|
||||
setBlocks(value.blocks);
|
||||
const next = { ...draftRef.current, title: value.title, excerpt: value.excerpt };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}, []);
|
||||
const history = useEditorHistory({
|
||||
initial: { blocks, title: draft.title, excerpt: draft.excerpt },
|
||||
onRestore: restoreHistory,
|
||||
});
|
||||
const historyRef = useRef(history);
|
||||
historyRef.current = history;
|
||||
|
||||
const undo = useCallback(() => historyRef.current.undo(), []);
|
||||
const redo = useCallback(() => historyRef.current.redo(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.shiftKey ? redo() : undo();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [redo, undo]);
|
||||
|
||||
const capabilities = bootstrap.capabilities || {};
|
||||
const limits: Limits = {
|
||||
@@ -130,11 +174,32 @@ function Studio(): JSX.Element {
|
||||
|
||||
const patchDraft = useCallback((patch: Partial<Draft>) => {
|
||||
dirtyRef.current = true;
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
const next = { ...draftRef.current, ...patch };
|
||||
draftRef.current = next;
|
||||
if ('title' in patch || 'excerpt' in patch) {
|
||||
historyRef.current.record({
|
||||
blocks: blocksRef.current,
|
||||
title: next.title,
|
||||
excerpt: next.excerpt,
|
||||
});
|
||||
}
|
||||
setDraft(next);
|
||||
}, []);
|
||||
|
||||
const onChangeBlocks = useCallback((next: any[]) => {
|
||||
dirtyRef.current = true;
|
||||
blocksRef.current = next;
|
||||
const signature = serialize(next);
|
||||
if (restoreBlockSignatureRef.current !== '' && signature === restoreBlockSignatureRef.current) {
|
||||
restoreBlockSignatureRef.current = '';
|
||||
setBlocks(next);
|
||||
return;
|
||||
}
|
||||
historyRef.current.record({
|
||||
blocks: next,
|
||||
title: draftRef.current.title,
|
||||
excerpt: draftRef.current.excerpt,
|
||||
});
|
||||
setBlocks(next);
|
||||
}, []);
|
||||
|
||||
@@ -183,7 +248,9 @@ function Studio(): JSX.Element {
|
||||
const versionId = Number(response.version_id || draft.versionId || 0);
|
||||
const rev = Number(response.rev || response.versionRev || draft.rev || 0);
|
||||
dirtyRef.current = false;
|
||||
setDraft((current) => ({ ...current, postId: savedId, versionId, rev }));
|
||||
const updated = { ...draftRef.current, postId: savedId, versionId, rev };
|
||||
draftRef.current = updated;
|
||||
setDraft(updated);
|
||||
setSaveState({ state: 'saved', at: new Date().toLocaleTimeString() });
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
@@ -220,7 +287,13 @@ function Studio(): JSX.Element {
|
||||
}
|
||||
const response = await apiPost(
|
||||
bootstrap.creator.submitUrl,
|
||||
buildPayload({ publish_now: publishNow, captcha_proof: captchaProof }),
|
||||
buildPayload({
|
||||
post_id: draftRef.current.postId,
|
||||
version_id: draftRef.current.versionId,
|
||||
rev: draftRef.current.rev,
|
||||
publish_now: publishNow,
|
||||
captcha_proof: captchaProof,
|
||||
}),
|
||||
nonce
|
||||
);
|
||||
dirtyRef.current = false;
|
||||
@@ -249,7 +322,7 @@ function Studio(): JSX.Element {
|
||||
try {
|
||||
const response = await apiPost(
|
||||
bootstrap.creator.previewUrl,
|
||||
{ post_id: draft.postId, version_id: draft.versionId },
|
||||
{ post_id: draftRef.current.postId, version_id: draftRef.current.versionId },
|
||||
nonce
|
||||
);
|
||||
if (response.url) {
|
||||
@@ -315,12 +388,10 @@ function Studio(): JSX.Element {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed.title === 'string') {
|
||||
dirtyRef.current = true;
|
||||
setDraft((current) => ({ ...current, title: parsed.title }));
|
||||
patchDraft({ title: parsed.title });
|
||||
}
|
||||
if (typeof parsed.content === 'string') {
|
||||
dirtyRef.current = true;
|
||||
setBlocks(parse(parsed.content));
|
||||
onChangeBlocks(parse(parsed.content));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -340,28 +411,59 @@ function Studio(): JSX.Element {
|
||||
(item: InsertItem) => {
|
||||
setInsertOpen(false);
|
||||
dirtyRef.current = true;
|
||||
setBlocks((current: any[]) => {
|
||||
// 空文档时直接替换初始的空白段落,避免留下一个空行。
|
||||
const next = [...current];
|
||||
if (next.length === 1 && next[0].name === 'core/paragraph' && !next[0].attributes?.content) {
|
||||
return [createBlock(item.name, item.attributes || {})];
|
||||
}
|
||||
return [...next, createBlock(item.name, item.attributes || {})];
|
||||
const current = [...blocksRef.current];
|
||||
let next: any[];
|
||||
// 空文档时直接替换初始的空白段落,避免留下一个空行。
|
||||
if (current.length === 1 && current[0].name === 'core/paragraph' && !current[0].attributes?.content) {
|
||||
next = [createBlock(item.name, item.attributes || {})];
|
||||
} else {
|
||||
next = [...current, createBlock(item.name, item.attributes || {})];
|
||||
}
|
||||
blocksRef.current = next;
|
||||
historyRef.current.record({
|
||||
blocks: next,
|
||||
title: draftRef.current.title,
|
||||
excerpt: draftRef.current.excerpt,
|
||||
});
|
||||
setBlocks(next);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="jio-creator" data-status={bootstrap.status || ''} data-blocks={selectedBlockCount}> <header className="jio-creator__bar">
|
||||
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'}>
|
||||
<i className="fa fas fa-angle-left" aria-hidden="true" />
|
||||
<span>{__('返回', 'jio')}</span>
|
||||
</a>
|
||||
<div className="jio-creator__brand">Jio Creator Studio</div>
|
||||
<div className="jio-creator__actions">
|
||||
<div className="jio-creator" data-status={bootstrap.status || ''} data-blocks={selectedBlockCount}>
|
||||
<header className="jio-creator__bar">
|
||||
<div className="jio-creator__bar-left">
|
||||
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'} aria-label={__('返回作者中心', 'jio')}>
|
||||
<i className="fa fas fa-angle-left" aria-hidden="true" />
|
||||
</a>
|
||||
<span className="jio-creator__bar-brand">Jio</span>
|
||||
<span className="jio-creator__bar-divider" aria-hidden="true" />
|
||||
<span className="jio-creator__bar-mode">
|
||||
{isPublishedEdit ? __('修改文章', 'jio') : __('写文章', 'jio')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__bar-center">
|
||||
<span className="jio-creator__doc-title">
|
||||
{draft.title.trim() || __('无标题文章', 'jio')}
|
||||
</span>
|
||||
<SaveState value={saveState} />
|
||||
<button type="button" className="jio-creator__ghost" onClick={() => void preview()}>
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__bar-actions">
|
||||
<div className="jio-creator__history" role="group" aria-label={__('编辑历史', 'jio')}>
|
||||
<button type="button" className="jio-creator__icon-btn" onClick={() => history.undo()} disabled={!history.canUndo} aria-label={__('撤销', 'jio')} title={__('撤销', 'jio')}>
|
||||
<i className="fa fas fa-undo" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="jio-creator__icon-btn" onClick={() => history.redo()} disabled={!history.canRedo} aria-label={__('重做', 'jio')} title={__('重做', 'jio')}>
|
||||
<i className="fa fas fa-redo" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="jio-creator__ghost jio-creator__save-draft" onClick={() => void save(false)}>
|
||||
{__('保存草稿', 'jio')}
|
||||
</button>
|
||||
<button type="button" className="jio-creator__ghost jio-creator__preview" onClick={() => void preview()}>
|
||||
{__('预览', 'jio')}
|
||||
</button>
|
||||
<button type="button" className="jio-creator__primary" onClick={() => setDrawerOpen(true)}>
|
||||
|
||||
Reference in new issue
Block a user