feat(creator-studio): two-row masthead with site logo and user avatar

顶栏改为知乎式两行结构:第一行左侧站点 LOGO、右侧当前用户头像,
第二行只保留正文工具条并居中,同时移除「Jio / 写文章」字样。

- bootstrap payload 新增 site/viewer 身份字段(复用 jio_get_site_logo_url
  与 jio_get_avatar_url,前端不拼 URL、不做权限判断)
- 拆分 draft 类型、TinyMCE 块运行时、顶栏组件,index.tsx 1065 → 735 行
- 清理死代码:未调用的 keepCaretInsideCode、永远无法打开的代码语言弹窗
  (setCodeLanguage 只会收到 null,codeTargetRef 从未赋值)及其样式,
  以及顶栏移除后失效的 bar-left/brand/mode/save-draft/ghost/primary 规则
- 契约测试、本地预览与截图脚本同步到新 DOM

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-08 22:20:10 +08:00
1 parent 5f31c58df2
commit 1970db0b56
17 files changed
+602 -580

No files matched your search

+32 -362
View File
@@ -14,6 +14,7 @@ import { apiPost } from './services/api';
import { PublishDrawer } from './panels/publish-drawer';
import { EditorToolbar } from './components/editor-toolbar';
import { PublishSettings } from './components/publish-settings';
import { StudioMasthead } from './components/studio-masthead';
import { type SaveStateValue } from './components/save-state';
import { useAutosave } from './hooks/use-autosave';
import { useLocalRecovery } from './hooks/use-local-recovery';
@@ -22,7 +23,6 @@ import { EDITOR_CONTENT_STYLE } from './editor-content-style';
import { hydrateBlocks, serializeBlocks } from './utils/serialize';
import { StatusBar } from './components/status-bar';
import {
CODE_LANGUAGES,
codeBlockMarkup,
dividerMarkup,
footnoteMarkup,
@@ -34,6 +34,13 @@ import {
type QuoteColor,
} from './utils/block-markup';
import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog';
import { type Draft, type Limits, initialDraft } from './draft';
import {
installBlockActions,
removeAdjacentBlock,
syncCodeLabels,
syncEditorHeight,
} from './editor-runtime';
import './styles/studio.scss';
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
@@ -41,296 +48,6 @@ const nonce: string = bootstrap.nonce || '';
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
const EDITOR_ID = 'jio-creator-content';
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
type PayState = {
enabled: boolean;
modo: 'cash' | 'points';
price: string;
points: number;
vip1Price: string;
vip2Price: string;
payLimit: number;
downloads: Array<Record<string, any>>;
};
type Limits = {
titleMin?: number;
titleMax?: number;
titlePlaceholder?: string;
priceMin?: number;
priceMax?: number;
pointsMin?: number;
pointsMax?: number;
};
type Schedule = { enabled: boolean; at: string };
type Draft = {
postId: number;
versionId: number;
rev: number;
title: string;
content: string;
schedule: Schedule;
categories: number[];
tags: string[];
custom: Record<string, string[]>;
featured: Featured;
pay: PayState;
};
function initialDraft(): Draft {
const pay = bootstrap.pay || {};
const selected = (bootstrap.categories && bootstrap.categories.selected) || [];
const tags = (bootstrap.tags && bootstrap.tags.items) || [];
return {
postId: Number(bootstrap.sourceId || 0),
versionId: Number(bootstrap.versionId || 0),
rev: Number(bootstrap.versionRev || 0),
title: String(bootstrap.title || ''),
content: String(bootstrap.content || ''),
schedule: { enabled: false, at: '' },
categories: selected.map(Number),
tags: tags.slice(),
custom: bootstrap.custom || {},
featured: bootstrap.featured || { type: '', data: {} },
pay: {
enabled: !!pay.enabled,
modo: pay.payModo === 'points' ? 'points' : 'cash',
price: String(pay.price || '0.00'),
points: Number(pay.points || 0),
vip1Price: String(pay.vip1Price || '0.00'),
vip2Price: String(pay.vip2Price || '0.00'),
payLimit: Number(pay.payLimit || 0),
downloads: Array.isArray(pay.downloads) ? pay.downloads : [],
},
};
}
/**
* Backspace / Delete 删除相邻的块级元素。
*
* 表格、代码块、引用、分割线这类内容不是普通文本,TinyMCE 默认不会在
* 空段落里按删除键时主动移除它们,导致「删不掉」。这里在光标处于空段落、
* 且紧邻目标块时直接移除该块。
*/
function removeAdjacentBlock(editor: any, event: any): void {
const key = Number(event.keyCode);
if (key !== 8 && key !== 46) {
return;
}
const selection = editor.selection;
if (!selection || !selection.isCollapsed()) {
return;
}
const body = editor.getBody();
const block = selection.getNode();
const isBlank = (node: any): boolean =>
!!node && node.nodeType === 1 && (node.textContent || '').trim() === '' &&
!editor.dom.getParent(node, 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder', body);
const isBlockElement = (node: any): boolean =>
!!node && node.nodeType === 1 && !!(node.matches?.('table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder'));
// 光标可能落在空段落里,也可能落在块级元素的边界(TinyMCE 常见行为);
// 两种情况下都优先删除紧邻的块,而不是让按键悄悄失效。
const blankContainer = isBlank(block) ? block : null;
const direct = blankContainer
? (key === 8 ? blankContainer.previousElementSibling : blankContainer.nextElementSibling)
: null;
if (isBlockElement(direct)) {
event.preventDefault();
editor.dom.remove(direct);
editor.nodeChanged();
editor.undoManager.add();
return;
}
// 光标停在块首/块尾时,删除块本身。
const caretBlock = editor.dom.getParent(block, 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder', body);
if (!caretBlock) {
return;
}
const caretIsAtEdge = (start: boolean): boolean => {
const probe = editor.dom.createRng();
probe.selectNodeContents(caretBlock);
probe.collapse(start);
const caret = selection.getRng();
return caret && typeof caret.compareBoundaryPoints === 'function'
&& caret.compareBoundaryPoints(start ? 1 : 3, probe) === 0;
};
if (!((key === 8 && caretIsAtEdge(true)) || (key === 46 && caretIsAtEdge(false)))) {
return;
}
event.preventDefault();
const parent = caretBlock.parentElement;
editor.dom.remove(caretBlock);
// 删掉最后一个块后补一个空段落,否则没有可放置光标的位置。
if (parent && parent.children.length === 0) {
editor.setContent('<p></p>');
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
}
editor.nodeChanged();
editor.undoManager.add();
}
/**
* 在编辑器里为每个代码块渲染语言角标,并保证回车不会跳出代码区。
*
* 角标是编辑期提示(纯 UI),保存前由 serializeBlocks 移除。
*/
/**
* 在代码块内回车时保持在代码里换行,而不是跳出到普通段落。
*
* TinyMCE 默认把 pre 当成不可持续输入的块,回车会新建段落,导致代码跑到
* 代码块外面。这里手动插入换行并保持光标在 code 内。
*/
/**
* 让 iframe 高度跟随正文增长,避免编辑器内部出现第二根滚动条。
*
* TinyMCE 的 iframe 不会自动撑高;这里测量内容高度并同步容器,
* 页面整体滚动由外层工作台负责。
*/
function syncEditorHeight(editor: any): void {
if (!editor || editor.removed) {
return;
}
const doc = editor.getDoc();
const iframe = editor.getContentAreaContainer()?.querySelector('iframe') as HTMLIFrameElement | null;
if (!doc || !iframe) {
return;
}
const height = Math.max(
320,
doc.documentElement.scrollHeight,
doc.body ? doc.body.scrollHeight : 0
);
iframe.style.height = `${height}px`;
iframe.style.overflow = 'hidden';
}
function keepCaretInsideCode(editor: any, event: any): boolean {
if (Number(event.keyCode) !== 13 || event.shiftKey) {
return false;
}
const pre = editor.dom.getParent(editor.selection.getNode(), 'pre.enlighter-pre');
if (!pre) {
return false;
}
event.preventDefault();
const code = pre.querySelector('code') || pre;
editor.selection.setCursorLocation(code, code.childNodes.length);
editor.insertContent('<br>');
editor.nodeChanged();
return true;
}
/** 可整体删除的块级元素选择器。 */
const BLOCK_SELECTOR = 'table,pre.enlighter-pre,hr,.quote_q,p';
/**
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
*
* 与 WPS 的块操作一致:鼠标悬停到块上时在右上角出现删除入口,
* 避免只能靠反复按退格键。
*/
function installBlockActions(editor: any): void {
const doc = editor.getDoc();
if (!doc || doc.getElementById('jio-block-actions')) {
return;
}
const bar = doc.createElement('div');
bar.id = 'jio-block-actions';
bar.className = 'jio-creator__block-actions';
bar.innerHTML =
'<button type="button" class="jio-creator__block-action" data-jio-block-delete title="' +
'删除此块' +
'" aria-label="删除此块"><span aria-hidden="true">&#10005;</span></button>';
doc.body.appendChild(bar);
let current: HTMLElement | null = null;
const hide = () => {
current = null;
bar.classList.remove('is-visible');
};
const show = (block: HTMLElement) => {
if (!block || block === doc.body) {
hide();
return;
}
// 空段落没有删除价值,交给退格键处理。
if (block.tagName === 'P' && (block.textContent || '').trim() === '') {
hide();
return;
}
current = block;
const rect = block.getBoundingClientRect();
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
bar.style.left = `${rect.right - 40}px`;
bar.classList.add('is-visible');
};
doc.addEventListener('mouseover', (event: MouseEvent) => {
const block = (event.target as HTMLElement)?.closest?.(BLOCK_SELECTOR) as HTMLElement | null;
if (block && block !== doc.body && !block.classList.contains('jio-code-lang')) {
show(block);
}
});
doc.addEventListener('mouseleave', hide);
bar.addEventListener('mousedown', (event: MouseEvent) => event.preventDefault());
bar.addEventListener('click', (event: MouseEvent) => {
const button = (event.target as HTMLElement)?.closest('[data-jio-block-delete]');
if (!button || !current) {
return;
}
event.preventDefault();
const target = current;
hide();
editor.undoManager.transact(() => {
editor.dom.remove(target);
});
editor.nodeChanged();
editor.undoManager.add();
if (editor.getBody().children.length === 0) {
editor.setContent('<p></p>');
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
}
syncEditorHeight(editor);
});
}
function syncCodeLabels(editor: any): void {
const root: HTMLElement | null = editor?.getBody?.() ?? null;
if (!root) {
return;
}
root.querySelectorAll<HTMLElement>('pre.enlighter-pre[data-jio-code]').forEach((pre) => {
const existing = pre.querySelector(':scope > .jio-code-lang');
const language = pre.getAttribute('data-enlighter-language') || 'generic';
if (existing) {
existing.setAttribute('data-jio-code-lang', language);
const label = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
if ((existing.textContent || '') !== label) {
existing.textContent = label;
}
return;
}
const badge = document.createElement('span');
badge.className = 'jio-code-lang';
badge.contentEditable = 'false';
badge.setAttribute('data-jio-code-lang', language);
badge.textContent = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
pre.insertBefore(badge, pre.firstChild);
});
}
function escapeAttribute(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
@@ -355,7 +72,7 @@ function plainText(content: string): string {
}
function Studio(): JSX.Element {
const [draft, setDraft] = useState<Draft>(initialDraft);
const [draft, setDraft] = useState<Draft>(() => initialDraft(bootstrap));
const draftRef = useRef(draft);
draftRef.current = draft;
const editorRef = useRef<any>(null);
@@ -372,8 +89,6 @@ function Studio(): JSX.Element {
}));
const [drawerOpen, setDrawerOpen] = useState(false);
const [blockDialog, setBlockDialog] = useState<BlockDialogKind | null>(null);
const [codeLanguage, setCodeLanguage] = useState<null | string>(null);
const codeTargetRef = useRef<any>(null);
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
const dirtyRef = useRef(false);
@@ -881,33 +596,30 @@ function Studio(): JSX.Element {
return (
<div className="jio-creator" data-status={bootstrap.status || ''} data-editor-ready={editorReady ? 'true' : 'false'}>
<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>
<EditorToolbar
ready={editorReady}
formatValue={formatValue}
onFormatChange={setFormatValue}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
capabilities={capabilities}
onCommand={command}
onRememberSelection={rememberSelection}
onOpenLink={openLink}
onOpenMedia={openMedia}
onInsertBlock={insertBlock}
onInsertQuote={insertQuote}
<div className="jio-creator__top">
<StudioMasthead
centerUrl={String(bootstrap.urls?.center || '')}
site={bootstrap.site || {}}
viewer={bootstrap.viewer || {}}
/>
</header>
<header className="jio-creator__bar">
<EditorToolbar
ready={editorReady}
formatValue={formatValue}
onFormatChange={setFormatValue}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
capabilities={capabilities}
onCommand={command}
onRememberSelection={rememberSelection}
onOpenLink={openLink}
onOpenMedia={openMedia}
onInsertBlock={insertBlock}
onInsertQuote={insertQuote}
/>
</header>
</div>
{recovery && (
<div className="jio-creator__notice" role="status">
@@ -962,48 +674,6 @@ function Studio(): JSX.Element {
onScheduleChange={(enabled, at) => patchDraft({ schedule: { enabled, at } })}
/>
{codeLanguage !== null && (
<div className="jio-creator-dialog" role="dialog" aria-modal="true" aria-labelledby="jio-code-lang-title">
<button type="button" className="jio-creator-dialog__backdrop" aria-label={__('关闭', 'jio')} onClick={() => setCodeLanguage(null)} />
<div className="jio-creator-dialog__panel">
<header className="jio-creator-dialog__head">
<h2 id="jio-code-lang-title">{__('代码语言', 'jio')}</h2>
<button type="button" className="jio-creator-dialog__close" onClick={() => setCodeLanguage(null)} aria-label={__('关闭', 'jio')}>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</header>
<div className="jio-creator-dialog__body">
<div className="jio-creator__lang-grid">
{CODE_LANGUAGES.map((item) => (
<button
key={item.value}
type="button"
className={`jio-creator__lang-item${item.value === codeLanguage ? ' is-active' : ''}`}
onClick={() => {
const target = codeTargetRef.current;
codeTargetRef.current = null;
setCodeLanguage(null);
if (!target) {
return;
}
target.setAttribute('data-enlighter-language', item.value);
const code = target.querySelector('code');
if (code) {
code.setAttribute('data-enlighter-language', item.value);
}
syncCodeLabels(editorRef.current);
updateContent(serializeBlocks(editorRef.current.getContent()));
}}
>
{item.label}
</button>
))}
</div>
</div>
</div>
</div>
)}
{blockDialog && (
<BlockDialog
kind={blockDialog}