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:
kate522andClaude Opus 5 committed 2026-11-06 17:36:15 +08:00
1 parent f5e9420266
commit 9e8913ea0a
9 files changed
+518 -50

No files matched your search

@@ -0,0 +1,121 @@
/**
* 工作台级撤销/重做历史。
*
* BlockEditorProvider 独立运行时不带 core/editor 的 post history store,
* 因此为标题、摘要、区块树保存快照。输入以 450ms 合并成一条历史记录,
* 避免每个字符都占用一步。
*
* @package Jio
*/
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
type Snapshot<T> = { value: T; signature: string };
type HistoryState<T> = { past: Snapshot<T>[]; future: Snapshot<T>[]; present: Snapshot<T>; pending: T | null };
type Options<T> = {
initial: T;
onRestore: (value: T) => void;
limit?: number;
mergeDelay?: number;
};
function copy<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function snapshot<T>(value: T): Snapshot<T> {
const cloned = copy(value);
return { value: cloned, signature: JSON.stringify(cloned) };
}
export function useEditorHistory<T>({ initial, onRestore, limit = 80, mergeDelay = 450 }: Options<T>) {
const state = useRef<HistoryState<T>>({
past: [],
future: [],
present: snapshot(initial),
pending: null,
});
const timer = useRef<number | null>(null);
const restoreRef = useRef(onRestore);
restoreRef.current = onRestore;
const [availability, setAvailability] = useState({ canUndo: false, canRedo: false });
const updateAvailability = useCallback(() => {
setAvailability({
canUndo: state.current.past.length > 0 || state.current.pending !== null,
canRedo: state.current.future.length > 0,
});
}, []);
const flush = useCallback(() => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
timer.current = null;
}
const current = state.current;
if (current.pending === null) {
return;
}
const next = snapshot(current.pending);
current.pending = null;
if (next.signature === current.present.signature) {
updateAvailability();
return;
}
current.past = [...current.past, current.present].slice(-limit);
current.present = next;
current.future = [];
updateAvailability();
}, [limit, updateAvailability]);
const record = useCallback(
(value: T) => {
state.current.pending = copy(value);
if (timer.current !== null) {
window.clearTimeout(timer.current);
}
timer.current = window.setTimeout(flush, mergeDelay);
updateAvailability();
},
[flush, mergeDelay, updateAvailability]
);
const undo = useCallback(() => {
flush();
const current = state.current;
if (!current.past.length) {
return false;
}
const previous = current.past[current.past.length - 1];
current.past = current.past.slice(0, -1);
current.future = [current.present, ...current.future].slice(0, limit);
current.present = previous;
restoreRef.current(copy(previous.value));
updateAvailability();
return true;
}, [flush, limit, updateAvailability]);
const redo = useCallback(() => {
flush();
const current = state.current;
if (!current.future.length) {
return false;
}
const next = current.future[0];
current.future = current.future.slice(1);
current.past = [...current.past, current.present].slice(-limit);
current.present = next;
restoreRef.current(copy(next.value));
updateAvailability();
return true;
}, [flush, limit, updateAvailability]);
useEffect(() => () => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
}
}, []);
return { ...availability, record, undo, redo, flush };
}
+126 -24
View File
@@ -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)}>
+223 -21
View File
@@ -21,6 +21,12 @@ $warn: #b4573f;
background: $paper;
}
/* 主题固定导航会给 body.nav-fixed 预留 88px;工作台隐藏了导航,不应保留空位。 */
body.jio-page-submit.nav-fixed,
body.jio-page-submit.has-static-nav {
padding-top: 0 !important;
}
.jio-page-submit .header,
.jio-page-submit .footer,
.jio-page-submit .float-btn,
@@ -50,6 +56,8 @@ $warn: #b4573f;
min-height: 100vh;
min-height: 100dvh;
/* 固定顶栏属于工作台 chrome,不随正文一起滚动。 */
padding-top: var(--jc-bar);
background: var(--jc-paper);
color: var(--jc-ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
@@ -68,43 +76,192 @@ $warn: #b4573f;
}
.jio-creator__bar {
position: sticky;
z-index: 30;
position: fixed;
z-index: 300;
top: 0;
right: 0;
left: 0;
display: flex;
height: var(--jc-bar);
align-items: center;
gap: 16px;
padding: 0 clamp(16px, 4vw, 40px);
justify-content: space-between;
gap: 20px;
padding: 0 clamp(16px, 3vw, 34px);
border-bottom: 1px solid var(--jc-line);
background: rgba(250, 251, 250, 0.92);
backdrop-filter: blur(10px);
background: rgba(250, 251, 250, 0.97);
backdrop-filter: blur(14px);
box-shadow: 0 2px 8px rgba(27, 46, 39, 0.035);
}
.jio-creator__bar-left,
.jio-creator__bar-center,
.jio-creator__bar-actions {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.jio-creator__bar-left {
flex: 0 0 auto;
}
.jio-creator__bar-center {
flex: 1 1 auto;
justify-content: center;
gap: 14px;
}
.jio-creator__doc-title {
max-width: min(32vw, 360px);
overflow: hidden;
color: #65736d;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.jio-creator__bar-actions {
flex: 0 0 auto;
gap: 8px;
}
.jio-creator__back {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
gap: 6px;
justify-content: center;
border-radius: 7px;
color: var(--jc-ink-soft);
text-decoration: none;
&:hover,
&:focus-visible {
background: #edf3f0;
color: var(--jc-accent);
outline: 0;
}
}
.jio-creator__brand {
margin-right: auto;
color: var(--jc-ink-soft);
font-size: 13px;
letter-spacing: 0.06em;
.jio-creator__bar-brand {
color: var(--jc-accent-strong);
font-family: "Songti SC", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 17px;
font-weight: 700;
letter-spacing: -0.04em;
}
.jio-creator__actions {
display: flex;
.jio-creator__bar-divider {
width: 1px;
height: 18px;
background: var(--jc-line);
}
.jio-creator__bar-mode {
color: var(--jc-ink-soft);
font-size: 13px;
}
.jio-creator__doc-title {
max-width: min(32vw, 360px);
overflow: hidden;
color: #65736d;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.jio-creator__history {
display: inline-flex;
align-items: center;
gap: 10px;
gap: 2px;
padding-right: 8px;
border-right: 1px solid var(--jc-line);
}
.jio-creator__icon-btn {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--jc-ink-soft);
cursor: pointer;
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background: #edf3f0;
color: var(--jc-accent-strong);
outline: 0;
}
&:disabled {
color: #c7d0cc;
cursor: default;
}
}
.jio-creator__save-draft {
border-color: var(--jc-line);
background: #fff;
color: var(--jc-ink-soft);
}
.jio-creator__save-draft:hover,
.jio-creator__save-draft:focus-visible {
border-color: #b7cdc3;
background: #f7faf8;
color: var(--jc-accent-strong);
}
.jio-creator__icon-btn {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--jc-ink-soft);
cursor: pointer;
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background: #edf3f0;
color: var(--jc-accent-strong);
outline: 0;
}
&:disabled {
color: #c7d0cc;
cursor: default;
}
}
.jio-creator__history {
display: inline-flex;
align-items: center;
gap: 2px;
padding-right: 8px;
border-right: 1px solid var(--jc-line);
}
.jio-creator__save-draft {
border-color: var(--jc-line);
background: #fff;
color: var(--jc-ink-soft);
}
.jio-creator__save-draft:hover,
.jio-creator__save-draft:focus-visible {
border-color: #b7cdc3;
background: #f7faf8;
color: var(--jc-accent-strong);
}
.jio-creator__save {
@@ -1136,16 +1293,61 @@ $warn: #b4573f;
@media (max-width: 767px) {
.jio-creator {
--jc-bar: 54px;
}
.jio-creator__brand {
display: none;
--jc-bar: 56px;
}
.jio-creator__bar {
gap: 10px;
gap: 8px;
padding: 0 10px;
}
.jio-creator__bar-left,
.jio-creator__bar-center,
.jio-creator__bar-actions {
gap: 6px;
}
.jio-creator__bar-brand {
font-size: 15px;
}
.jio-creator__bar-mode,
.jio-creator__bar-divider,
.jio-creator__doc-title,
.jio-creator__save-draft {
display: none;
}
.jio-creator__bar-center {
flex: 0 0 auto;
justify-content: flex-start;
}
.jio-creator__bar-actions {
flex: 1 1 auto;
justify-content: flex-end;
}
.jio-creator__icon-btn {
width: 28px;
height: 30px;
}
.jio-creator__history {
gap: 0;
padding-right: 4px;
}
.jio-creator__preview {
min-height: 32px;
padding: 0 9px;
font-size: 12px;
}
.jio-creator__primary {
min-height: 32px;
padding: 0 12px;
font-size: 13px;
}
.jio-creator__save time {