Files
wordpress-theme-aksrt/assets/creator-studio/src/index.tsx
T
kate522andClaude Opus 5 330df53195 feat(creator-studio): multi-select category picker and creatable tag input
分类从平铺复选标签改为带搜索的多选下拉(触发器内是已选令牌),
标签改为令牌输入框:回车 / 逗号 / 顿号 / 分号 / 换行切分,输入时列出
站点已有标签作为建议,面板内可一键新建。

- 抽出 useAnchoredPanel,工具条下拉与新的选择器共用同一套 portal 定位
- bootstrap 的 tags 增加 suggestions(按使用量取前 200 个已有标签)
- 清理 .jio-creator-inline 与 chip 内的死样式

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-09 17:35:52 +08:00

855 lines
28 KiB
TypeScript

/**
* Jio Creator Studio — 前台 TinyMCE 写作工作台。
*
* TinyMCE 负责文章正文编辑,固定操作栏通过 TinyMCE API 执行排版与媒体命令;
* Jio REST 继续负责保存、审核、权限、付费配置与私有附件归属。
*
* @package Jio
*/
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { apiPost } from './services/api';
import { PublishDrawer } from './panels/publish-drawer';
import { PublishDialog } from './components/publish-dialog';
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';
import { normalizePayForRequest } from './utils/pay';
import { EDITOR_CONTENT_STYLE } from './editor-content-style';
import { hydrateBlocks, serializeBlocks } from './utils/serialize';
import { StatusBar } from './components/status-bar';
import {
codeBlockMarkup,
dividerMarkup,
footnoteMarkup,
hiddenContentMarkup,
mathMarkup,
quoteMarkup,
retargetHiddenContent,
tableMarkup,
type HideKind,
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,
syncEditorHeight,
} from './editor-runtime';
import './styles/studio.scss';
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
const nonce: string = bootstrap.nonce || '';
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
const EDITOR_ID = 'jio-creator-content';
function escapeAttribute(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function safeHttpUrl(value: unknown): string {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
try {
const url = new URL(raw, window.location.href);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : '';
} catch (error) {
return '';
}
}
function plainText(content: string): string {
const container = document.createElement('div');
container.innerHTML = content;
return (container.textContent || '').replace(/\s+/g, '');
}
function Studio(): JSX.Element {
const [draft, setDraft] = useState<Draft>(() => initialDraft(bootstrap));
const draftRef = useRef(draft);
draftRef.current = draft;
const editorRef = useRef<any>(null);
const editorBookmarkRef = useRef<any>(null);
const pendingRestoreContentRef = useRef<string | null>(null);
const editorReadyRef = useRef(false);
const saveActionRef = useRef<() => void>(() => undefined);
const [editorReady, setEditorReady] = useState(false);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const [formatValue, setFormatValue] = useState('p');
const [alignValue, setAlignValue] = useState('left');
const [blockValue, setBlockValue] = useState('p');
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
at: '',
}));
const [drawerOpen, setDrawerOpen] = useState(false);
const [publishOpen, setPublishOpen] = useState(false);
const [blockDialog, setBlockDialog] = useState<BlockDialogKind | null>(null);
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
const dirtyRef = useRef(false);
const editRevisionRef = useRef(0);
const capabilities = bootstrap.capabilities || {};
const limits: Limits = {
titleMin: Number(bootstrap.limits?.titleMin || 1),
titleMax: Number(bootstrap.limits?.titleMax || 30),
titlePlaceholder: String(bootstrap.limits?.titlePlaceholder || __('请输入标题', 'jio')),
priceMin: Number(bootstrap.pay?.priceMin || 0),
priceMax: Number(bootstrap.pay?.priceMax || 0),
pointsMin: Number(bootstrap.pay?.pointsMin || 0),
pointsMax: Number(bootstrap.pay?.pointsMax || 0),
};
const isPublishedEdit = !!bootstrap.isPublished;
const charCount = useMemo(() => Array.from(plainText(draft.content)).length, [draft.content]);
const patchDraft = useCallback((patch: Partial<Draft>) => {
dirtyRef.current = true;
editRevisionRef.current += 1;
const next = { ...draftRef.current, ...patch };
draftRef.current = next;
setDraft(next);
}, []);
const updateContent = useCallback((content: string) => {
if (content === draftRef.current.content) {
return;
}
dirtyRef.current = true;
editRevisionRef.current += 1;
const next = { ...draftRef.current, content };
draftRef.current = next;
setDraft(next);
}, []);
const updateHistoryAvailability = useCallback((editor: any) => {
const manager = editor?.undoManager;
if (!manager) return;
setHistoryAvailability({
canUndo: typeof manager.hasUndo === 'function' && manager.hasUndo(),
canRedo: typeof manager.hasRedo === 'function' && manager.hasRedo(),
});
}, []);
const updateFormatValue = useCallback((editor: any) => {
const node = editor?.selection?.getNode();
const block = node ? editor.dom.getParent(node, 'p,h1,h2,h3,h4,h5,h6') || node : null;
const name = block?.nodeName?.toLowerCase() || 'p';
setFormatValue(['h1', 'h2', 'h3', 'h4'].includes(name) ? name : 'p');
}, []);
/**
* 同步工具栏的激活态:对齐方式、列表类型、以及光标是否在引用 / 隐藏内容里。
* 只读编辑器状态,不写 DOM,所以可以安全地在 NodeChange 上高频调用。
*/
const updateBlockValue = useCallback((editor: any) => {
const node = editor?.selection?.getNode();
if (!node) {
return;
}
const body = editor.getBody();
const alignment = String(editor.formatter?.match?.(['alignleft', 'aligncenter', 'alignright']) || '');
setAlignValue(alignment === 'aligncenter' ? 'center' : alignment === 'alignright' ? 'right' : 'left');
const quote = editor.dom.getParent(node, '.quote_q', body);
if (quote) {
const color = Array.from(quote.classList as unknown as string[]).find((name) => name.indexOf('qe_wzk_') === 0);
setBlockValue(color || 'quote');
return;
}
if (editor.dom.getParent(node, 'ul', body)) {
setBlockValue('ul');
return;
}
if (editor.dom.getParent(node, 'ol', body)) {
setBlockValue('ol');
return;
}
setBlockValue('p');
}, []);
const buildPayload = useCallback((extra: Record<string, any> = {}) => {
const current = draftRef.current;
return {
post_id: current.postId,
version_id: current.versionId,
rev: current.rev,
title: current.title,
content: current.content,
categories: current.categories,
tags: current.tags,
custom: current.custom,
featured: current.featured,
schedule: {
enabled: !!current.schedule.enabled,
at: current.schedule.enabled ? current.schedule.at : '',
},
pay: normalizePayForRequest(current.pay),
...extra,
};
}, []);
const save = useCallback(
async (autosave = false): Promise<boolean> => {
const editor = editorRef.current;
if (editor && !editor.removed) {
updateContent(serializeBlocks(editor.getContent()));
}
const current = draftRef.current;
if (!dirtyRef.current && (current.postId > 0 || current.versionId > 0)) {
return true;
}
const requestRevision = editRevisionRef.current;
// 空文档(连块级内容都没有)不发请求:服务端会拒绝,前端也不该把它当失败。
const hasDocument = current.title.trim() !== '' || plainText(current.content).trim() !== ''
|| /<(?:img|table|pre|hr|iframe|video)/i.test(current.content);
if (!hasDocument) {
return true;
}
setSaveState({ state: 'saving', at: '' });
try {
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
const updated = {
...draftRef.current,
postId: Number(response.post_id || draftRef.current.postId || 0),
versionId: Number(response.version_id || draftRef.current.versionId || 0),
rev: Number(response.rev || response.versionRev || draftRef.current.rev || 0),
};
if ((window as any).jioSubmitData) {
(window as any).jioSubmitData.postId = updated.postId;
}
const settled = editRevisionRef.current === requestRevision;
dirtyRef.current = !settled;
draftRef.current = updated;
setDraft(updated);
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleTimeString() : '' });
return settled;
} catch (error: any) {
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
return false;
}
},
[buildPayload]
);
saveActionRef.current = () => void save(false);
const submit = useCallback(
async (publishNow: boolean): Promise<void> => {
const persisted = await save(false);
if (!persisted) {
setNotice({ tone: 'error', text: __('内容保存失败,请重试后再提交', 'jio') });
return;
}
setSaveState({ state: 'submitting', at: '' });
try {
let captchaProof = '';
const captcha = (window as any).JioCaptcha;
if (bootstrap.captcha?.enabled && captcha && typeof captcha.run === 'function') {
const payload = await captcha.run('front_submit');
if (payload === null) {
setSaveState({ state: 'ready', at: '' });
setNotice({ tone: 'error', text: __('请先完成人机验证', 'jio') });
return;
}
captchaProof = String(payload?.jio_captcha_proof || '');
}
const response = await apiPost(
bootstrap.creator.submitUrl,
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;
setSaveState({ state: 'submitted', at: '' });
setNotice({ tone: 'ok', text: String(response.message || __('已提交', 'jio')) });
setPublishOpen(false);
setDrawerOpen(false);
const target = response.url || response.goto || bootstrap.urls?.center;
if (target) {
window.setTimeout(() => {
window.location.href = target;
}, 900);
}
} catch (error: any) {
setSaveState({ state: 'error', at: '' });
setNotice({ tone: 'error', text: String(error?.message || __('提交失败', 'jio')) });
}
},
[buildPayload, save]
);
const preview = useCallback(async (): Promise<void> => {
const saved = await save(false);
if (!saved) {
return;
}
try {
const response = await apiPost(
bootstrap.creator.previewUrl,
{ post_id: draftRef.current.postId, version_id: draftRef.current.versionId },
nonce
);
if (response.url) {
window.open(String(response.url), '_blank', 'noopener');
}
} catch (error: any) {
setNotice({ tone: 'error', text: String(error?.message || __('预览失败', 'jio')) });
}
}, [save]);
const { markLocalSaved, clear } = useLocalRecovery({
key: STORAGE_KEY,
snapshot: { title: draft.title, content: draft.content, at: new Date().toISOString() },
onFound: (savedAt: string) => setRecovery({ savedAt }),
});
const autosave = useAutosave({
enabled: !!capabilities.isLoggedIn,
onSave: async () => {
const ok = await save(true);
if (ok) {
clear();
}
return ok;
},
onLocal: markLocalSaved,
});
const autosaveSchedule = autosave.schedule;
useEffect(() => {
if (dirtyRef.current) {
autosaveSchedule();
}
}, [draft, autosaveSchedule]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirtyRef.current) {
return;
}
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', beforeUnload);
return () => window.removeEventListener('beforeunload', beforeUnload);
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
void save(false);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [save]);
useEffect(() => {
const wpEditor = (window as any).wp?.editor;
if (!wpEditor || typeof wpEditor.initialize !== 'function') {
setNotice({ tone: 'error', text: __('TinyMCE 加载失败,请刷新页面后重试。', 'jio') });
return undefined;
}
const editorSettings: Record<string, any> = {
quicktags: false,
tinymce: {
menubar: false,
statusbar: false,
resize: false,
toolbar1: '',
toolbar2: '',
toolbar3: '',
plugins: 'paste,link,lists,wordpress,wplink',
wpautop: true,
body_class: 'jio-creator__tinymce',
content_css: bootstrap.creator?.contentCss || '',
content_style: EDITOR_CONTENT_STYLE,
// iframe 高度随后由 autoresize 随正文增长,这里只给初始值。
height: 520,
placeholder: String(bootstrap.limits?.contentPlaceholder || ''),
setup: (editor: any) => {
editorRef.current = editor;
editor.on('init', () => {
editorReadyRef.current = true;
setEditorReady(true);
if (pendingRestoreContentRef.current !== null) {
editor.setContent(hydrateBlocks(pendingRestoreContentRef.current));
pendingRestoreContentRef.current = null;
} else {
editor.setContent(hydrateBlocks(editor.getContent()));
}
syncEditorHeight(editor);
installBlockActions(editor);
updateHistoryAvailability(editor);
updateFormatValue(editor);
updateBlockValue(editor);
});
editor.on('NodeChange', () => {
updateFormatValue(editor);
updateBlockValue(editor);
});
editor.on('input change Undo Redo AddUndo', () => {
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncEditorHeight(editor);
});
editor.on('keydown', (event: any) => {
if ((event.metaKey || event.ctrlKey) && event.keyCode === 83) {
event.preventDefault();
saveActionRef.current();
return;
}
removeAdjacentBlock(editor, event);
});
},
},
};
wpEditor.initialize(EDITOR_ID, editorSettings);
return () => {
wpEditor.remove?.(EDITOR_ID);
editorReadyRef.current = false;
editorRef.current = null;
};
}, [updateContent, updateBlockValue, updateFormatValue]);
const restore = useCallback(() => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const patch: Partial<Draft> = {};
if (typeof parsed.title === 'string') patch.title = parsed.title;
if (typeof parsed.content === 'string') patch.content = parsed.content;
patchDraft(patch);
if (typeof parsed.content === 'string') {
if (editorReadyRef.current) {
editorRef.current?.setContent(parsed.content);
} else {
pendingRestoreContentRef.current = parsed.content;
}
}
}
} catch (error) {
// 快照损坏时忽略,不影响服务端内容。
}
setRecovery(null);
}, [patchDraft]);
const rememberSelection = useCallback(() => {
const editor = editorRef.current;
if (editor && !editor.removed) {
editorBookmarkRef.current = editor.selection.getBookmark(2, true);
}
}, []);
const focusEditor = useCallback(() => {
const editor = editorRef.current;
if (!editor || editor.removed) {
return null;
}
editor.focus();
if (editorBookmarkRef.current) {
try {
editor.selection.moveToBookmark(editorBookmarkRef.current);
} catch (error) {
// TinyMCE discards invalid bookmarks after content replacement.
}
editorBookmarkRef.current = null;
}
return editor;
}, []);
const command = useCallback((name: string, value?: string) => {
const editor = focusEditor();
if (!editor) {
return;
}
if (name === 'undo') {
editor.undoManager.undo();
} else if (name === 'redo') {
editor.undoManager.redo();
} else if (name === 'format') {
const block = value || 'p';
editor.execCommand('FormatBlock', false, block);
updateFormatValue(editor);
} else if (name === 'align') {
editor.execCommand(
value === 'center' ? 'JustifyCenter' : value === 'right' ? 'JustifyRight' : 'JustifyLeft'
);
updateBlockValue(editor);
} else {
editor.execCommand(name);
}
editor.nodeChanged();
}, [focusEditor, updateBlockValue, updateFormatValue]);
const openLink = useCallback(() => {
const editor = focusEditor();
if (!editor) {
return;
}
const wpLink = (window as any).wpLink;
if (wpLink && typeof wpLink.open === 'function') {
wpLink.open(editor.id);
return;
}
const url = window.prompt(__('输入链接地址', 'jio'), 'https://');
const safeUrl = url ? safeHttpUrl(url) : '';
if (safeUrl) {
editor.execCommand('mceInsertLink', false, { href: safeUrl });
}
}, [focusEditor]);
const insertHtml = useCallback((html: string) => {
const editor = focusEditor();
if (!editor || !html) {
return;
}
editor.insertContent(html);
// 让刚插入的块成为一次可撤销的操作(insertContent 本身不进 undo 栈)。
editor.undoManager.add();
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncEditorHeight(editor);
}, [focusEditor]);
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
const insertBlock = useCallback((kind: string) => {
rememberSelection();
if (kind === 'table' || kind === 'math' || kind === 'footnote') {
setBlockDialog(kind as BlockDialogKind);
return;
}
if (kind === 'code') {
insertHtml(codeBlockMarkup());
return;
}
if (kind === 'ul' || kind === 'ol') {
const editor = focusEditor();
if (editor) {
editor.execCommand(kind === 'ol' ? 'InsertOrderedList' : 'InsertUnorderedList');
editor.undoManager.add();
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateBlockValue(editor);
}
return;
}
if (kind === 'divider') {
insertHtml(dividerMarkup());
}
}, [focusEditor, insertHtml, rememberSelection, updateBlockValue]);
/**
* 把当前选区(或光标所在的顶层段落)替换成一个整块。
*
* - 有选区:包住选中的内容;
* - 光标停在顶层段落里:整段替换,避免正文被复制成两份;
* - 其它位置:插入一个空块。
*/
const wrapSelection = useCallback((build: (innerHtml: string) => string) => {
const editor = focusEditor();
if (!editor) {
return;
}
const selected = String(editor.selection.getContent({ format: 'html' }) || '').trim();
if (selected === '') {
const node = editor.selection.getNode();
if (node && node.nodeName === 'P' && node.parentNode === editor.getBody()) {
// 选中整段后再插入,让 TinyMCE 用新块替换掉原段落。
editor.selection.select(node);
}
}
const inner = String(editor.selection.getContent({ format: 'html' }) || '').trim();
// insertContent 不进 undo 栈,这里包一层 transact 让整次插入可一次撤销。
editor.undoManager.transact(() => {
editor.insertContent(build(inner));
});
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncEditorHeight(editor);
updateBlockValue(editor);
}, [focusEditor, updateBlockValue]);
const insertQuote = useCallback((colorClass: QuoteColor) => {
const editor = focusEditor();
if (!editor) {
return;
}
// 已有引用块:只换配色,正文保持不变。
const existing = editor.dom.getParent(editor.selection.getNode(), '.quote_q');
if (existing) {
const classes = ['quote_q', 'quote-mce'];
if (colorClass) {
classes.push(colorClass);
}
existing.className = classes.join(' ');
editor.nodeChanged();
updateBlockValue(editor);
updateContent(serializeBlocks(editor.getContent()));
return;
}
wrapSelection((inner) => quoteMarkup(inner, colorClass));
}, [focusEditor, updateBlockValue, wrapSelection]);
const insertHidden = useCallback((kind: HideKind) => {
const editor = focusEditor();
if (!editor) {
return;
}
// 已选中隐藏块:换可见性档位,正文保持不变。
const existing = editor.dom.getParent(editor.selection.getNode(), '.tinymce-hide') as HTMLElement | null;
if (existing) {
retargetHiddenContent(existing, kind);
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
return;
}
wrapSelection((inner) => hiddenContentMarkup(kind, inner));
}, [focusEditor, wrapSelection]);
const applyBlockDialog = useCallback((result: BlockDialogResult) => {
setBlockDialog(null);
// 弹窗抢走焦点后,插入前把光标放回正文的原选区。
if (result.kind === 'table') {
insertHtml(tableMarkup(result));
} else if (result.kind === 'math') {
insertHtml(mathMarkup(result));
} else {
insertHtml(footnoteMarkup(result.note));
}
}, [insertHtml]);
const openMedia = useCallback((type: 'image' | 'video' | 'file') => {
const editor = editorRef.current;
const media = (window as any).JioSubmitMedia;
if (!editor || !media || typeof media.open !== 'function') {
setNotice({ tone: 'error', text: __('媒体工具尚未加载,请刷新页面后重试。', 'jio') });
return;
}
rememberSelection();
media.open({
type,
multiple: type === 'image' ? undefined : false,
onSelect: (selected: any) => {
const items = Array.isArray(selected) ? selected : [selected];
const target = focusEditor();
if (!target) return;
const markup = items.map((item) => {
const url = safeHttpUrl(item?.large_url || item?.url);
if (!url) return '';
const id = Number(item.attachment_id || item.id || 0);
const idAttribute = id > 0 ? ` data-edit-file-id="${id}"` : '';
if (type === 'image') {
const fullUrl = safeHttpUrl(item?.url || url);
const fullAttribute = fullUrl ? ` data-full-url="${escapeAttribute(fullUrl)}"` : '';
return `<p><img src="${escapeAttribute(url)}"${idAttribute} alt="${escapeAttribute(String(item.name || item.title || ''))}"${fullAttribute}></p>`;
}
if (type === 'file') {
const name = String(item.filename || item.name || url.split('/').pop() || '');
return `<p><a class="jio-file-link" href="${escapeAttribute(url)}"${idAttribute}><i class="fa fas fa-paperclip" aria-hidden="true"></i> ${escapeAttribute(name)}</a></p>`;
}
return `<div contenteditable="false" data-video-url="${escapeAttribute(url)}" data-video-name="${escapeAttribute(String(item.filename || item.name || url))}"${idAttribute} class="new-dplayer post-dplayer dplayer"></div><p></p>`;
}).join('');
if (markup) {
target.insertContent(markup);
target.nodeChanged();
updateContent(target.getContent());
updateHistoryAvailability(target);
}
},
onEmbed: (html: string) => {
const target = focusEditor();
if (target) {
target.insertContent(`${html}<p></p>`);
target.nodeChanged();
updateContent(target.getContent());
updateHistoryAvailability(target);
}
},
});
}, [focusEditor, rememberSelection]);
return (
<div className="jio-creator" data-status={bootstrap.status || ''} data-editor-ready={editorReady ? 'true' : 'false'}>
<div className="jio-creator__top">
<StudioMasthead
centerUrl={String(bootstrap.urls?.center || '')}
site={bootstrap.site || {}}
viewer={bootstrap.viewer || {}}
/>
<header className="jio-creator__bar">
<EditorToolbar
ready={editorReady}
formatValue={formatValue}
onFormatChange={setFormatValue}
alignValue={alignValue}
blockValue={blockValue}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
capabilities={capabilities}
onCommand={command}
onRememberSelection={rememberSelection}
onOpenLink={openLink}
onOpenMedia={openMedia}
onInsertBlock={insertBlock}
onInsertQuote={insertQuote}
onInsertHidden={insertHidden}
/>
</header>
</div>
{recovery && (
<div className="jio-creator__notice" role="status">
<span>{__('发现本地未同步的草稿。', 'jio')}</span>
<button type="button" onClick={restore}>{__('恢复', 'jio')}</button>
<button type="button" onClick={() => setRecovery(null)}>{__('忽略', 'jio')}</button>
</div>
)}
{notice && (
<div className={`jio-creator__notice is-${notice.tone}`} role="status">
<span>{notice.text}</span>
<button type="button" onClick={() => setNotice(null)} aria-label={__('关闭', 'jio')}>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</div>
)}
{isPublishedEdit && (
<p className="jio-creator__hint">{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}</p>
)}
<main className="jio-creator__canvas">
<label className="jio-creator__title">
<span className="screen-reader-text">{__('文章标题', 'jio')}</span>
<textarea
value={draft.title}
onChange={(event) => patchDraft({ title: event.target.value })}
rows={1}
placeholder={sprintf(
/* translators: %d: max title length */
__('请输入标题(最多 %d 个字)', 'jio'),
Number(limits.titleMax) || 100
)}
spellCheck={false}
maxLength={Number(limits.titleMax) || 100}
/>
</label>
<div className="jio-creator__editor" data-editor-ready={editorReady ? 'true' : 'false'}>
<textarea id={EDITOR_ID} defaultValue={draft.content} onChange={(event) => updateContent(event.target.value)} aria-label={__('文章正文', 'jio')} />
</div>
</main>
<PublishSettings
featured={draft.featured}
canEditCover={!!capabilities.canUploadImage}
canSchedule={!!capabilities.canSchedule}
scheduleEnabled={!!draft.schedule.enabled}
scheduleAt={draft.schedule.at}
pay={draft.pay}
canPay={!!capabilities.canPay}
taxonomy={{
categories: (bootstrap.categories && bootstrap.categories.items) || [],
tags: {
enabled: !!bootstrap.tags?.enabled,
suggestions: (bootstrap.tags && bootstrap.tags.suggestions) || [],
},
customDefs: bootstrap.customDefs || [],
}}
categories={draft.categories}
tags={draft.tags}
custom={draft.custom}
onFeaturedChange={(featured) => patchDraft({ featured })}
onScheduleChange={(enabled, at) => patchDraft({ schedule: { enabled, at } })}
onPayEnabledChange={(enabled) => patchDraft({ pay: { ...draftRef.current.pay, enabled } })}
onCategoriesChange={(categories) => patchDraft({ categories })}
onTagsChange={(tags) => patchDraft({ tags })}
onCustomChange={(custom) => patchDraft({ custom })}
onOpenPaySettings={() => setDrawerOpen(true)}
/>
{blockDialog && (
<BlockDialog
kind={blockDialog}
onSubmit={applyBlockDialog}
onClose={() => setBlockDialog(null)}
/>
)}
<StatusBar
charCount={charCount}
saveState={saveState}
isPublishedEdit={isPublishedEdit}
saving={!editorReady || saveState.state === 'saving' || saveState.state === 'submitting'}
onSaveDraft={() => void save(false)}
onPreview={() => void preview()}
onPublish={() => setPublishOpen(true)}
/>
{drawerOpen && (
<PublishDrawer
draft={draft}
setDraft={patchDraft}
capabilities={capabilities}
isPublishedEdit={isPublishedEdit}
onClose={() => setDrawerOpen(false)}
privateStorage={!!bootstrap.privateStorage}
privateUploads={bootstrap.privateUploads || []}
/>
)}
{publishOpen && (
<PublishDialog
canPublishNow={!!capabilities.canPublishNow}
isPublishedEdit={isPublishedEdit}
captcha={!!bootstrap.captcha?.enabled}
saving={saveState.state === 'saving' || saveState.state === 'submitting'}
canSubmit={(draft.title || '').trim().length >= Number(limits.titleMin || 1)}
onClose={() => setPublishOpen(false)}
onSaveDraft={() => void save(false)}
onSubmit={(publishNow: boolean) => void submit(publishNow)}
/>
)}
</div>
);
}
function mount() {
const target = document.getElementById('jio-creator-studio');
if (!target) {
return;
}
target.classList.add('is-ready');
createRoot(target).render(<Studio />);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
} else {
mount();
}
export { Studio };