feat(creator-studio): switch writing editor to TinyMCE

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-06 20:51:18 +08:00
1 parent 4868de2cda
commit 384d2b7aa0
25 files changed
+1255 -7076

No files matched your search

@@ -0,0 +1,96 @@
import { __ } from '@wordpress/i18n';
type Capabilities = Record<string, boolean>;
type Props = {
ready: boolean;
canUndo: boolean;
canRedo: boolean;
formatValue: string;
onFormatChange: (value: string) => void;
capabilities: Capabilities;
onCommand: (name: string, value?: string) => void;
onRememberSelection: () => void;
onOpenLink: () => void;
onOpenMedia: (type: 'image' | 'video') => void;
};
export function EditorToolbar({
ready,
canUndo,
canRedo,
formatValue,
onFormatChange,
capabilities,
onCommand,
onRememberSelection,
onOpenLink,
onOpenMedia,
}: Props) {
return (
<div
className="jio-creator__toolbar-tools"
role="toolbar"
aria-label={__('正文编辑工具', 'jio')}
onMouseDown={(event) => {
onRememberSelection();
if ((event.target as HTMLElement).closest('button')) event.preventDefault();
}}
>
<div className="jio-creator__toolbar-group" role="group" aria-label={__('撤销和重做', 'jio')}>
<button type="button" className="jio-creator__tool-btn" disabled={!canUndo} onClick={() => onCommand('undo')} title={__('撤销', 'jio')} aria-label={__('撤销', 'jio')}>
<i className="fa fas fa-undo" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!canRedo} onClick={() => onCommand('redo')} title={__('重做', 'jio')} aria-label={__('重做', 'jio')}>
<i className="fa fas fa-redo" aria-hidden="true" />
</button>
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<select
className="jio-creator__format-select"
aria-label={__('段落样式', 'jio')}
value={formatValue}
disabled={!ready}
onFocus={onRememberSelection}
onChange={(event) => {
onFormatChange(event.target.value);
onCommand('format', event.target.value);
}}
>
<option value="p">{__('正文', 'jio')}</option>
<option value="h1">{__('标题 1', 'jio')}</option>
<option value="h2">{__('标题 2', 'jio')}</option>
<option value="h3">{__('标题 3', 'jio')}</option>
<option value="blockquote">{__('引用', 'jio')}</option>
</select>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<div className="jio-creator__toolbar-group" role="group" aria-label={__('文字格式', 'jio')}>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Bold')} title={__('加粗', 'jio')} aria-label={__('加粗', 'jio')}>
<i className="fa fas fa-bold" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Italic')} title={__('斜体', 'jio')} aria-label={__('斜体', 'jio')}>
<i className="fa fas fa-italic" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Underline')} title={__('下划线', 'jio')} aria-label={__('下划线', 'jio')}>
<i className="fa fas fa-underline" aria-hidden="true" />
</button>
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<div className="jio-creator__toolbar-group" role="group" aria-label={__('媒体和链接', 'jio')}>
{capabilities.canUploadImage && (
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('image')} title={__('插入图片', 'jio')}>
<i className="fa far fa-image" aria-hidden="true" /><span>{__('图片', 'jio')}</span>
</button>
)}
{(capabilities.canUploadVideo || capabilities.canEmbed) && (
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('video')} title={__('插入视频', 'jio')}>
<i className="fa fas fa-video" aria-hidden="true" /><span>{__('视频', 'jio')}</span>
</button>
)}
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={onOpenLink} title={__('插入链接', 'jio')}>
<i className="fa fas fa-link" aria-hidden="true" /><span>{__('链接', 'jio')}</span>
</button>
</div>
</div>
);
}
@@ -1,73 +0,0 @@
/**
* 图片宽度预设(公众号式四档)。
*
* 通过 block filter 给 core/image 追加「半屏 / 中等 / 通栏 / 全宽」四档宽度,
* 选中图片时出现在区块工具条里。宽度存进图片区块的 className,序列化到
* post_content 后由前台样式生效,不需要自定义区块或额外元数据。
*
* @package Jio
*/
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import { BlockControls } from '@wordpress/block-editor';
import { ToolbarGroup } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const WIDTH_CLASSES = [
{ key: 'half', label: __('半屏', 'jio'), className: 'jio-img-w--half' },
{ key: 'medium', label: __('中等', 'jio'), className: 'jio-img-w--medium' },
{ key: 'wide', label: __('通栏', 'jio'), className: 'jio-img-w--wide' },
{ key: 'full', label: __('全宽', 'jio'), className: 'jio-img-w--full' },
];
function currentPreset(className: string): string {
const found = WIDTH_CLASSES.find((w) => (className || '').split(/\s+/).includes(w.className));
return found ? found.key : 'wide';
}
function withImageWidthControls(BlockEdit: any) {
return (props: any) => {
if (props.name !== 'core/image') {
return <BlockEdit {...props} />;
}
const className = String(props.attributes?.className || '');
const active = currentPreset(className);
const setPreset = (preset: (typeof WIDTH_CLASSES)[number]) => {
const rest = className
.split(/\s+/)
.filter((c) => c && !WIDTH_CLASSES.some((w) => w.className === c));
props.setAttributes({ className: [...rest, preset.className].join(' ') });
};
return (
<>
<BlockEdit {...props} />
<BlockControls group="inline">
<ToolbarGroup
controls={WIDTH_CLASSES.map((preset) => ({
title: preset.label,
// 用文字而不是图标,四档宽度靠标签更易分辨。
children: preset.label,
isActive: active === preset.key,
onClick: () => setPreset(preset),
}))}
/>
</BlockControls>
</>
);
};
}
export function installImageWidthPresets(): void {
if ((window as any).__jioImageWidthInstalled) {
return;
}
(window as any).__jioImageWidthInstalled = true;
addFilter(
'editor.BlockEdit',
'jio/creator-studio/image-width',
createHigherOrderComponent(withImageWidthControls, 'withJioImageWidth')
);
}
@@ -1,121 +0,0 @@
/**
* 工作台级撤销/重做历史。
*
* 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 };
}
+297 -218
View File
@@ -1,47 +1,30 @@
/**
* Jio Creator Studio — 前台沉浸式创作界面。
* Jio Creator Studio — 前台 TinyMCE 写作工作台。
*
* Gutenberg 负责区块解析、编辑与序列化;本入口只搭建工作台外壳、发布配置抽屉
* 与保存/预览/提交编排。所有写入都走 Jio REST(CreatorRest),服务端负责权限、
* 字段清洗、审核状态与私有附件归属。
* TinyMCE 负责文章正文编辑,固定操作栏通过 TinyMCE API 执行排版与媒体命令;
* Jio REST 继续负责保存、审核、权限、付费配置与私有附件归属。
*
* @package Jio
*/
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { createBlock, parse, serialize } from '@wordpress/blocks';
import { registerCoreBlocks } from '@wordpress/block-library';
import { useDispatch, useSelect } from '@wordpress/data';
import { BlockEditorProvider, BlockList, BlockTools } from '@wordpress/block-editor';
import { count as countWords } from '@wordpress/wordcount';
import { __ } from '@wordpress/i18n';
import { apiPost } from './services/api';
import { installApiFetchNonce } from './services/api-fetch';
import { installMediaUploadBridge } from './services/media';
import { installImageWidthPresets } from './extensions/image-width';
import { PublishDrawer } from './panels/publish-drawer';
import { CoverStrip } from './components/cover-strip';
import { AuthorRow } from './components/author-row';
import { InsertPanel, type InsertItem } from './panels/insert-panel';
import { buildInsertGroups } from './utils/insert-items';
import { EditorToolbar } from './components/editor-toolbar';
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';
// 核心区块注册只做一次;重复调用会触发 "Block type already registered"。
const coreRegistered = (window as any).__jioCoreBlocksRegistered;
if (!coreRegistered && typeof registerCoreBlocks === 'function') {
registerCoreBlocks();
(window as any).__jioCoreBlocksRegistered = true;
}
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 = {
@@ -107,58 +90,50 @@ function initialDraft(): Draft {
};
}
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 [blocks, setBlocks] = useState(() => parse(draft.content || ''));
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
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 [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 editRevisionRef = useRef(0);
const capabilities = bootstrap.capabilities || {};
const limits: Limits = {
@@ -174,97 +149,103 @@ function Studio(): JSX.Element {
const patchDraft = useCallback((patch: Partial<Draft>) => {
dirtyRef.current = true;
editRevisionRef.current += 1;
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);
const updateContent = useCallback((content: string) => {
if (content === draftRef.current.content) {
return;
}
historyRef.current.record({
blocks: next,
title: draftRef.current.title,
excerpt: draftRef.current.excerpt,
});
setBlocks(next);
dirtyRef.current = true;
editRevisionRef.current += 1;
const next = { ...draftRef.current, content };
draftRef.current = next;
setDraft(next);
}, []);
const serialized = useMemo(() => serialize(blocks), [blocks]);
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(),
});
}, []);
// 中文没有空格分词,wordcount 的 'words' 会把整段算成 1。
// 'characters_excluding_spaces' 才是中文场景该用的口径(与前台 TinyMCE 字数一致)。
const charCount = useMemo(() => {
const text = serialized.replace(/<[^>]*>/g, ' ');
return countWords(text, 'characters_excluding_spaces', {});
}, [serialized]);
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> = {}) => ({
post_id: draft.postId,
version_id: draft.versionId,
rev: draft.rev,
title: draft.title,
excerpt: draft.excerpt,
content: serialized,
categories: draft.categories,
tags: draft.tags,
custom: draft.custom,
featured: draft.featured,
pay: normalizePayForRequest(draft.pay),
const charCount = useMemo(() => Array.from(plainText(draft.content)).length, [draft.content]);
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,
excerpt: current.excerpt,
content: current.content,
categories: current.categories,
tags: current.tags,
custom: current.custom,
featured: current.featured,
pay: normalizePayForRequest(current.pay),
...extra,
}),
[draft, serialized]
);
};
}, []);
const save = useCallback(
async (autosave = false): Promise<boolean> => {
if (!dirtyRef.current && (draft.postId > 0 || draft.versionId > 0)) {
const editor = editorRef.current;
if (editor && !editor.removed) {
updateContent(editor.getContent());
}
const current = draftRef.current;
if (!dirtyRef.current && (current.postId > 0 || current.versionId > 0)) {
return true;
}
// 空文档不触发自动保存:服务端会拒绝无标题/无内容的写入,
// 保留本地快照即可,避免把“保存失败”当成真的故障。
const hasDocument = draft.title.trim() !== '' || serialized.replace(/<[^>]*>/g, '').trim() !== '';
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 savedId = Number(response.post_id || draft.postId || 0);
const versionId = Number(response.version_id || draft.versionId || 0);
const rev = Number(response.rev || response.versionRev || draft.rev || 0);
dirtyRef.current = false;
const updated = { ...draftRef.current, postId: savedId, versionId, rev };
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: 'saved', at: new Date().toLocaleTimeString() });
return true;
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, draft.postId, draft.versionId, draft.rev]
[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') });
@@ -272,8 +253,6 @@ function Studio(): JSX.Element {
}
setSaveState({ state: 'submitting', at: '' });
try {
// 站点启用「前台投稿」人机验证时,先完成挑战再提交;
// 服务端仍会独立校验证明,前端只是把凭证带过去。
let captchaProof = '';
const captcha = (window as any).JioCaptcha;
if (bootstrap.captcha?.enabled && captcha && typeof captcha.run === 'function') {
@@ -331,11 +310,11 @@ function Studio(): JSX.Element {
} catch (error: any) {
setNotice({ tone: 'error', text: String(error?.message || __('预览失败', 'jio')) });
}
}, [draft.postId, draft.versionId, save]);
}, [save]);
const { markLocalSaved, clear } = useLocalRecovery({
key: STORAGE_KEY,
snapshot: { title: draft.title, content: serialized, at: new Date().toISOString() },
snapshot: { title: draft.title, content: draft.content, at: new Date().toISOString() },
onFound: (savedAt: string) => setRecovery({ savedAt }),
});
@@ -357,7 +336,7 @@ function Studio(): JSX.Element {
if (dirtyRef.current) {
autosaveSchedule();
}
}, [draft, serialized, autosaveSchedule]);
}, [draft, autosaveSchedule]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
@@ -382,56 +361,198 @@ function Studio(): JSX.Element {
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: 'body{margin:0;color:#243330;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;font-size:16px;line-height:1.85;letter-spacing:.01em}p{margin:0 0 1.1em}h1,h2,h3,h4,h5,h6{margin:1.5em 0 .65em;line-height:1.4}img,iframe{max-width:100%}img{height:auto}',
height: 650,
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(pendingRestoreContentRef.current);
pendingRestoreContentRef.current = null;
}
updateHistoryAvailability(editor);
updateFormatValue(editor);
});
editor.on('NodeChange', () => updateFormatValue(editor));
editor.on('input change Undo Redo AddUndo', () => {
updateContent(editor.getContent());
updateHistoryAvailability(editor);
});
editor.on('keydown', (event: any) => {
if ((event.metaKey || event.ctrlKey) && event.keyCode === 83) {
event.preventDefault();
saveActionRef.current();
}
});
},
},
};
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);
if (typeof parsed.title === 'string') {
patchDraft({ title: parsed.title });
}
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') {
onChangeBlocks(parse(parsed.content));
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 selectedBlockCount = useSelect(
(select: any) => select('core/block-editor').getBlockCount(),
[]
);
const insertGroups = useMemo(() => buildInsertGroups(capabilities), [capabilities]);
const insertBlock = useCallback(
(item: InsertItem) => {
setInsertOpen(false);
dirtyRef.current = true;
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 || {})];
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.
}
blocksRef.current = next;
historyRef.current.record({
blocks: next,
title: draftRef.current.title,
excerpt: draftRef.current.excerpt,
});
setBlocks(next);
},
[]
);
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 openMedia = useCallback((type: 'image' | 'video') => {
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>`;
}
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-blocks={selectedBlockCount}>
<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')}>
@@ -444,24 +565,22 @@ function Studio(): JSX.Element {
</span>
</div>
<div className="jio-creator__bar-center">
<span className="jio-creator__doc-title">
{draft.title.trim() || __('无标题文章', 'jio')}
</span>
<EditorToolbar
ready={editorReady}
formatValue={formatValue}
onFormatChange={setFormatValue}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
capabilities={capabilities}
onCommand={command}
onRememberSelection={rememberSelection}
onOpenLink={openLink}
onOpenMedia={openMedia}
/>
<div className="jio-creator__bar-actions jio-creator__toolbar-extension" data-toolbar-extension aria-label={__('工具扩展区', 'jio')}>
<SaveState value={saveState} />
</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 type="button" className="jio-creator__save-draft" onClick={() => void save(false)} disabled={!editorReady}>
<i className="fa fas fa-save" aria-hidden="true" /><span>{__('保存草稿', 'jio')}</span>
</button>
<button type="button" className="jio-creator__ghost jio-creator__preview" onClick={() => void preview()}>
{__('预览', 'jio')}
@@ -490,9 +609,7 @@ function Studio(): JSX.Element {
)}
{isPublishedEdit && (
<p className="jio-creator__hint">
{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}
</p>
<p className="jio-creator__hint">{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}</p>
)}
<main className="jio-creator__canvas">
@@ -526,51 +643,16 @@ function Studio(): JSX.Element {
<AuthorRow author={bootstrap.author || {}} />
<div className="jio-creator__editor">
<BlockEditorProvider
value={blocks}
onInput={onChangeBlocks}
onChange={onChangeBlocks}
settings={{
hasFixedToolbar: false,
supportsLayout: false,
mediaUpload: (window as any).jioCreatorMediaUpload,
}}
>
<BlockTools>
<BlockList />
</BlockTools>
</BlockEditorProvider>
{/* 公众号式常驻插入按钮:始终停在正文末尾,点击打开内容面板。 */}
<button
type="button"
className="jio-creator__insert"
onClick={() => setInsertOpen(true)}
aria-haspopup="dialog"
aria-expanded={insertOpen}
>
<i className="fa fas fa-plus" aria-hidden="true" />
<span>{__('添加内容', 'jio')}</span>
</button>
<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>
<footer className="jio-creator__footer">
<span>{__('字数', 'jio')}:{charCount}</span>
<span>
{__('约', 'jio')} {Math.max(1, Math.round(charCount / 400))} {__('分钟阅读', 'jio')}
</span>
<span>{__('约', 'jio')} {Math.max(1, Math.round(charCount / 400))} {__('分钟阅读', 'jio')}</span>
</footer>
</main>
{insertOpen && (
<InsertPanel
groups={insertGroups}
onInsert={insertBlock}
onClose={() => setInsertOpen(false)}
/>
)}
{drawerOpen && (
<PublishDrawer
draft={draft}
@@ -601,9 +683,6 @@ function mount() {
if (!target) {
return;
}
installApiFetchNonce(nonce);
installMediaUploadBridge(bootstrap, nonce);
installImageWidthPresets();
target.classList.add('is-ready');
createRoot(target).render(<Studio />);
}
@@ -1,122 +0,0 @@
/**
* 悬浮插入面板(公众号式)。
*
* 正文底部常驻一个「+」,点开是分组网格:常用文本、媒体、样式。
* 桌面端为居中浮层,移动端贴底弹出;键盘可操作,Esc 关闭。
*
* @package Jio
*/
import { useEffect, useMemo, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
export type InsertItem = {
name: string;
title: string;
icon: string;
attributes?: Record<string, any>;
};
export type InsertGroup = {
label: string;
items: InsertItem[];
};
type Props = {
groups: InsertGroup[];
onInsert: (item: InsertItem) => void;
onClose: () => void;
};
export function InsertPanel({ groups, onInsert, onClose }: Props) {
const panelRef = useRef<HTMLDivElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const items = useMemo(() => groups.flatMap((group) => group.items), [groups]);
useEffect(() => {
closeRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
return;
}
// 在网格内用方向键移动焦点,避免必须用鼠标。
const buttons = Array.from(
panelRef.current?.querySelectorAll<HTMLButtonElement>('.jio-insert-item') || []
);
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (index === -1) {
return;
}
event.preventDefault();
const columns = window.innerWidth <= 767 ? 4 : 6;
const delta =
event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowDown' ? columns : -columns;
const next = Math.min(buttons.length - 1, Math.max(0, index + delta));
buttons[next]?.focus();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
return (
<div className="jio-insert" role="dialog" aria-modal="true" aria-label={__('添加内容', 'jio')}>
<button
type="button"
className="jio-insert__backdrop"
aria-label={__('关闭', 'jio')}
onClick={onClose}
/>
<div className="jio-insert__panel" ref={panelRef}>
<header className="jio-insert__head">
<h2>{__('添加内容', 'jio')}</h2>
<button
type="button"
className="jio-insert__close"
onClick={onClose}
ref={closeRef}
aria-label={__('关闭', 'jio')}
>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</header>
<div className="jio-insert__body">
{groups.map((group) =>
group.items.length === 0 ? null : (
<section className="jio-insert__group" key={group.label}>
<h3>{group.label}</h3>
<div className="jio-insert__grid">
{group.items.map((item) => (
<button
key={item.name}
type="button"
className="jio-insert-item"
onClick={() => onInsert(item)}
>
<span className="jio-insert-item__icon" aria-hidden="true">
<i className={item.icon} />
</span>
<span className="jio-insert-item__label">{item.title}</span>
</button>
))}
</div>
</section>
)
)}
{items.length === 0 && (
<p className="jio-creator-muted">{__('当前账号没有可插入的内容类型。', 'jio')}</p>
)}
</div>
</div>
</div>
);
}
@@ -1,33 +0,0 @@
/**
* 给 @wordpress/api-fetch 注入 REST nonce。
*
* WordPress 仅在后台为 wp-api 注入 wpApiSettings(含 nonce),前台工作台里
* Gutenberg 的 core-data 请求(如 /wp/v2/users/me)因此全部 403。这里统一挂一个
* nonce 中间件,让区块编辑器的数据请求与 Jio REST 使用同一次页面加载的 nonce。
*
* @package Jio
*/
import apiFetch from '@wordpress/api-fetch';
export function installApiFetchNonce(nonce: string): void {
if (!nonce || typeof apiFetch.use !== 'function') {
return;
}
if ((window as any).__jioApiFetchNonceInstalled) {
return;
}
(window as any).__jioApiFetchNonceInstalled = true;
// createNonceMiddleware 在收到 rest_cookie_invalid_nonce 时会带着 nonce 重试,
// 比在每次请求上手工加 header 更稳妥。
const factory = (apiFetch as any).createNonceMiddleware;
if (typeof factory === 'function') {
apiFetch.use(factory(nonce));
return;
}
apiFetch.use((options: any, next: any) => {
options.headers = { ...(options.headers || {}), 'X-WP-Nonce': nonce };
return next(options);
});
}
@@ -1,68 +0,0 @@
/**
* 编辑器媒体上传桥。
*
* Gutenberg 核心图片区块通过 block-editor settings.mediaUpload 上传;这里把它接到
* Jio 前台上传接口,保留 Jio 的能力校验、文件校验与待认领生命周期,而不是让区块
* 直接走 /wp/v2/media。
*
* @package Jio
*/
export function installMediaUploadBridge(bootstrap: Record<string, any>, nonce: string) {
const url = bootstrap?.blocks?.mediaUploadUrl;
if (!url) {
return;
}
const maxBytes = Number(bootstrap?.media?.maxBytes || 0);
const allowed = bootstrap?.capabilities?.canUploadImage !== false;
window['jioCreatorMediaUpload'] = (payload: any) => {
const { file, onError, onSuccess } = payload || {};
if (!file || typeof file === 'object' && !(file instanceof File)) {
onError?.('无效的文件');
return;
}
if (!allowed) {
onError?.('当前账号不能上传图片');
return;
}
if (maxBytes > 0 && file.size > maxBytes) {
onError?.('图片大小超过限制');
return;
}
const body = new FormData();
body.append('file', file);
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': nonce },
body,
})
.then((response) =>
response.json().then((data) => {
if (!response.ok) {
throw new Error((data && data.message) || '上传失败');
}
return data;
})
)
.then((data) => {
onSuccess?.({
id: Number(data.attachment_id || data.id || 0),
url: String(data.url || ''),
alt: '',
caption: '',
title: String(data.title || data.name || ''),
width: Number(data.width || 0),
height: Number(data.height || 0),
sizes: data.sizes || {},
mime: String(data.mime || ''),
});
})
.catch((error) => {
onError?.(error?.message || '上传失败');
});
};
}
@@ -0,0 +1,424 @@
@use './tokens' as *;
.jio-creator-drawer {
position: fixed;
z-index: 500;
inset: 0;
display: flex;
justify-content: flex-end;
}
.jio-creator-drawer__backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(23, 34, 31, 0.32);
cursor: default;
}
.jio-creator-drawer__panel {
position: relative;
display: flex;
width: min(460px, 100%);
height: 100%;
flex-direction: column;
background: #fff;
box-shadow: -20px 0 60px rgba(20, 32, 28, 0.16);
}
.jio-creator-drawer__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid $line;
h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
}
}
.jio-creator-drawer__close {
border: 0;
background: transparent;
color: $ink-soft;
cursor: pointer;
}
.jio-creator-drawer__body {
flex: 1;
overflow-y: auto;
padding: 20px 24px 32px;
}
.jio-creator-drawer__note {
margin: 0 0 20px;
padding: 12px 14px;
border-left: 3px solid $accent;
background: #f2f7f4;
color: $ink-soft;
font-size: 13px;
line-height: 1.6;
}
.jio-creator-drawer__foot {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 24px calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid $line;
background: #fff;
.jio-creator-primary {
flex: 1;
}
}
.jio-creator-drawer__actions {
display: flex;
align-items: center;
gap: 12px;
}
.jio-creator-drawer__captcha {
min-height: 40px;
}
.jio-creator-field {
margin-bottom: 26px;
> h3 {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: $ink;
}
}
.jio-creator-muted {
margin: 0;
color: $ink-soft;
font-size: 12px;
line-height: 1.6;
}
.jio-creator-error {
margin: 8px 0 0;
color: $warn;
font-size: 12px;
}
.jio-creator-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.jio-creator-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid $line;
border-radius: 999px;
color: $ink-soft;
font-size: 13px;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
&.is-active {
border-color: $accent;
background: #eef5f2;
color: $accent-strong;
}
button {
border: 0;
background: transparent;
cursor: pointer;
color: inherit;
}
}
.jio-creator-inline {
display: flex;
gap: 8px;
margin-top: 10px;
input[type="text"],
input:not([type]) {
flex: 1;
min-width: 0;
}
input[type="text"],
input:not([type]) {
height: 36px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 8px;
}
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
}
.jio-creator-switch {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin: 0 0 4px;
font-size: 13px;
}
}
.jio-creator-toggle {
position: relative;
flex: 0 0 auto;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
span {
display: block;
width: 42px;
height: 24px;
border-radius: 999px;
background: #d6dfda;
transition: background 0.18s ease;
&::after {
display: block;
width: 18px;
height: 18px;
margin: 3px;
border-radius: 50%;
background: #fff;
content: "";
transition: transform 0.18s ease;
}
}
input:checked + span {
background: $accent;
}
input:checked + span::after {
transform: translateX(18px);
}
input:focus-visible + span {
outline: 2px solid $accent;
outline-offset: 2px;
}
}
.jio-creator-pay {
margin-top: 16px;
padding: 16px;
border: 1px solid $line;
border-radius: 10px;
background: #fcfdfc;
}
.jio-creator-input {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
font-size: 13px;
input {
width: 140px;
height: 36px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 8px;
text-align: right;
}
}
.jio-creator-cover {
position: relative;
overflow: hidden;
border-radius: 10px;
img {
display: block;
width: 100%;
aspect-ratio: 2 / 1;
object-fit: cover;
}
button {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
}
}
.jio-creator-cover-empty {
display: flex;
min-height: 120px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
border: 1px dashed $line;
border-radius: 10px;
background: #fafcfb;
color: $ink-soft;
font-size: 13px;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
}
.jio-creator-attachments {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid $line;
}
.jio-creator-attachments__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h4 {
margin: 0;
font-size: 13px;
}
}
.jio-creator-upload {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 8px;
color: $accent-strong;
font-size: 12px;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
}
.jio-creator-files {
margin: 12px 0 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f0f4f1;
font-size: 13px;
}
button {
margin-left: auto;
border: 0;
background: transparent;
color: $ink-soft;
cursor: pointer;
}
}
.jio-creator-files__name {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
i {
color: $accent;
}
}
.jio-creator-reuse {
margin-top: 14px;
font-size: 13px;
summary {
cursor: pointer;
color: $ink-soft;
}
ul {
margin: 10px 0 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
button {
margin-left: auto;
border: 1px solid $line;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
}
.jio-creator-link {
border: 0;
background: transparent;
color: var(--jc-ink-soft, #{$ink-soft});
font-size: 13px;
text-decoration: underline;
cursor: pointer;
}
@@ -0,0 +1,7 @@
$ink: #243330;
$ink-soft: #5c6b66;
$paper: #fafbfa;
$line: #e2e9e5;
$accent: #267665;
$accent-strong: #1d5f52;
$warn: #b4573f;
File diff suppressed because it is too large. Load diff
@@ -1,65 +0,0 @@
/**
* 插入面板的可插入条目。
*
* 只列出当前账号真正能用的类型:图片/视频依赖上传能力,付费片段依赖付费能力。
* 区块名用 WordPress 核心与 Jio 自有区块的真实注册名,避免插入时报
* "Block type is not registered"。
*
* @package Jio
*/
import { __ } from '@wordpress/i18n';
import type { InsertGroup } from '../panels/insert-panel';
type Capabilities = {
canUploadImage?: boolean;
canUploadVideo?: boolean;
canPay?: boolean;
canEmbed?: boolean;
};
export function buildInsertGroups(capabilities: Capabilities): InsertGroup[] {
const text: InsertGroup = {
label: __('文字', 'jio'),
items: [
{ name: 'core/paragraph', title: __('正文', 'jio'), icon: 'fa fas fa-align-left' },
{ name: 'core/heading', title: __('标题', 'jio'), icon: 'fa fas fa-heading', attributes: { level: 2 } },
{ name: 'core/list', title: __('列表', 'jio'), icon: 'fa fas fa-list-ul' },
{ name: 'core/quote', title: __('引用', 'jio'), icon: 'fa fas fa-quote-left' },
],
};
const media: InsertGroup = {
label: __('媒体', 'jio'),
items: [],
};
if (capabilities.canUploadImage) {
media.items.push({ name: 'core/image', title: __('图片', 'jio'), icon: 'fa far fa-image' });
}
if (capabilities.canUploadVideo) {
media.items.push({ name: 'core/video', title: __('视频', 'jio'), icon: 'fa fas fa-video' });
}
if (capabilities.canEmbed) {
media.items.push({ name: 'core/embed', title: __('嵌入', 'jio'), icon: 'fa fas fa-code' });
}
const blocks: InsertGroup = {
label: __('组件', 'jio'),
items: [
{ name: 'core/code', title: __('代码', 'jio'), icon: 'fa fas fa-terminal' },
{ name: 'core/separator', title: __('分隔线', 'jio'), icon: 'fa fas fa-minus' },
{ name: 'core/table', title: __('表格', 'jio'), icon: 'fa fas fa-table' },
],
};
if (capabilities.canPay) {
blocks.items.push({
name: 'jio/paid-fragment',
title: __('付费内容', 'jio'),
icon: 'fa fas fa-lock',
});
}
return [text, media, blocks];
}