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