Files
wordpress-theme-aksrt/assets/creator-studio/src/index.tsx
T

1064 lines
35 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 { EditorToolbar } from './components/editor-toolbar';
import { PublishSettings } from './components/publish-settings';
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 {
CODE_LANGUAGES,
codeBlockMarkup,
dividerMarkup,
footnoteMarkup,
listMarkup,
mathMarkup,
quoteMarkup,
tableMarkup,
tocMarkup,
type QuoteColor,
} from './utils/block-markup';
import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog';
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';
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;');
}
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);
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 [saveState, setSaveState] = useState<SaveStateValue>(() => ({
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
at: '',
}));
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);
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,blockquote') || node : null;
const name = block?.nodeName?.toLowerCase() || 'p';
setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : '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() !== '';
if (autosave && !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')) });
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()));
}
syncCodeLabels(editor);
syncEditorHeight(editor);
installBlockActions(editor);
updateHistoryAvailability(editor);
updateFormatValue(editor);
});
editor.on('NodeChange', () => updateFormatValue(editor));
// 代码块右上角的语言标签:点击整块即可换语言,等价于公众号的「高亮」入口。
editor.on('input change Undo Redo AddUndo', () => {
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncCodeLabels(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]);
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') {
if (value === 'p') {
editor.execCommand('FormatBlock', false, 'p');
} else {
editor.formatter.toggle(value || 'h2');
}
updateFormatValue(editor);
} else {
editor.execCommand(name);
}
editor.nodeChanged();
}, [focusEditor]);
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);
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncCodeLabels(editor);
}, [focusEditor]);
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
const insertBlock = useCallback((kind: string) => {
rememberSelection();
// code 走「语言选择」弹窗,其余三类走通用插入弹窗。
if (kind === 'table' || kind === 'math' || kind === 'footnote') {
setBlockDialog(kind as BlockDialogKind);
return;
}
if (kind === 'code') {
// 直接插入;语言可稍后点代码块右上角的角标更换。
insertHtml(codeBlockMarkup('generic'));
return;
}
if (kind === 'list') {
insertHtml(listMarkup(false));
return;
}
if (kind === 'toc') {
insertHtml(tocMarkup());
return;
}
if (kind === 'divider') {
insertHtml(dividerMarkup());
}
}, [insertHtml, rememberSelection]);
const insertQuote = useCallback((colorClass: QuoteColor) => {
const editor = focusEditor();
if (!editor) {
return;
}
const selected = String(editor.selection.getContent() || '').trim();
const existing = editor.dom.getParent(editor.selection.getNode(), '.quote_q');
if (existing) {
existing.className = `quote_q quote-mce${colorClass ? ` ${colorClass}` : ''}`;
editor.nodeChanged();
return;
}
insertHtml(quoteMarkup(selected, colorClass));
}, [focusEditor, insertHtml]);
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'}>
<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}
/>
</header>
{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}
onFeaturedChange={(featured) => patchDraft({ featured })}
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}
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={() => setDrawerOpen(true)}
/>
{drawerOpen && (
<PublishDrawer
draft={draft}
setDraft={patchDraft}
capabilities={capabilities}
limits={limits}
taxonomy={{
categories: (bootstrap.categories && bootstrap.categories.items) || [],
tags: !!bootstrap.tags?.enabled,
customDefs: bootstrap.customDefs || [],
}}
isPublishedEdit={isPublishedEdit}
onClose={() => setDrawerOpen(false)}
onSaveDraft={() => void save(false)}
onSubmit={(publishNow: boolean) => void submit(publishNow)}
privateStorage={!!bootstrap.privateStorage}
privateUploads={bootstrap.privateUploads || []}
captcha={!!bootstrap.captcha?.enabled}
saving={saveState.state === 'saving' || saveState.state === 'submitting'}
/>
)}
</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 };