feat(creator-studio): switch writing editor to TinyMCE
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4868de2cda
commit
384d2b7aa0
25 files changed
+1255
-7076
No files matched your search
+297
-218
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 />);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user