Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1064 lines
35 KiB
TypeScript
1064 lines
35 KiB
TypeScript
/**
|
|
* Jio Creator Studio — 前台 TinyMCE 写作工作台。
|
|
*
|
|
* TinyMCE 负责文章正文编辑,固定操作栏通过 TinyMCE API 执行排版与媒体命令;
|
|
* Jio REST 继续负责保存、审核、权限、付费配置与私有附件归属。
|
|
*
|
|
* @package Jio
|
|
*/
|
|
|
|
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
|
|
import { __, sprintf } from '@wordpress/i18n';
|
|
|
|
import { apiPost } from './services/api';
|
|
import { PublishDrawer } from './panels/publish-drawer';
|
|
import { EditorToolbar } from './components/editor-toolbar';
|
|
import { PublishSettings } from './components/publish-settings';
|
|
import { type SaveStateValue } from './components/save-state';
|
|
import { useAutosave } from './hooks/use-autosave';
|
|
import { useLocalRecovery } from './hooks/use-local-recovery';
|
|
import { normalizePayForRequest } from './utils/pay';
|
|
import { EDITOR_CONTENT_STYLE } from './editor-content-style';
|
|
import { hydrateBlocks, serializeBlocks } from './utils/serialize';
|
|
import { StatusBar } from './components/status-bar';
|
|
import {
|
|
CODE_LANGUAGES,
|
|
codeBlockMarkup,
|
|
dividerMarkup,
|
|
footnoteMarkup,
|
|
listMarkup,
|
|
mathMarkup,
|
|
quoteMarkup,
|
|
tableMarkup,
|
|
tocMarkup,
|
|
type QuoteColor,
|
|
} from './utils/block-markup';
|
|
import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog';
|
|
import './styles/studio.scss';
|
|
|
|
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
|
|
const nonce: string = bootstrap.nonce || '';
|
|
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
|
|
const EDITOR_ID = 'jio-creator-content';
|
|
|
|
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
|
|
type PayState = {
|
|
enabled: boolean;
|
|
modo: 'cash' | 'points';
|
|
price: string;
|
|
points: number;
|
|
vip1Price: string;
|
|
vip2Price: string;
|
|
payLimit: number;
|
|
downloads: Array<Record<string, any>>;
|
|
};
|
|
|
|
type Limits = {
|
|
titleMin?: number;
|
|
titleMax?: number;
|
|
titlePlaceholder?: string;
|
|
priceMin?: number;
|
|
priceMax?: number;
|
|
pointsMin?: number;
|
|
pointsMax?: number;
|
|
};
|
|
|
|
type Schedule = { enabled: boolean; at: string };
|
|
|
|
type Draft = {
|
|
postId: number;
|
|
versionId: number;
|
|
rev: number;
|
|
title: string;
|
|
content: string;
|
|
schedule: Schedule;
|
|
categories: number[];
|
|
tags: string[];
|
|
custom: Record<string, string[]>;
|
|
featured: Featured;
|
|
pay: PayState;
|
|
};
|
|
|
|
function initialDraft(): Draft {
|
|
const pay = bootstrap.pay || {};
|
|
const selected = (bootstrap.categories && bootstrap.categories.selected) || [];
|
|
const tags = (bootstrap.tags && bootstrap.tags.items) || [];
|
|
return {
|
|
postId: Number(bootstrap.sourceId || 0),
|
|
versionId: Number(bootstrap.versionId || 0),
|
|
rev: Number(bootstrap.versionRev || 0),
|
|
title: String(bootstrap.title || ''),
|
|
content: String(bootstrap.content || ''),
|
|
schedule: { enabled: false, at: '' },
|
|
categories: selected.map(Number),
|
|
tags: tags.slice(),
|
|
custom: bootstrap.custom || {},
|
|
featured: bootstrap.featured || { type: '', data: {} },
|
|
pay: {
|
|
enabled: !!pay.enabled,
|
|
modo: pay.payModo === 'points' ? 'points' : 'cash',
|
|
price: String(pay.price || '0.00'),
|
|
points: Number(pay.points || 0),
|
|
vip1Price: String(pay.vip1Price || '0.00'),
|
|
vip2Price: String(pay.vip2Price || '0.00'),
|
|
payLimit: Number(pay.payLimit || 0),
|
|
downloads: Array.isArray(pay.downloads) ? pay.downloads : [],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Backspace / Delete 删除相邻的块级元素。
|
|
*
|
|
* 表格、代码块、引用、分割线这类内容不是普通文本,TinyMCE 默认不会在
|
|
* 空段落里按删除键时主动移除它们,导致「删不掉」。这里在光标处于空段落、
|
|
* 且紧邻目标块时直接移除该块。
|
|
*/
|
|
function removeAdjacentBlock(editor: any, event: any): void {
|
|
const key = Number(event.keyCode);
|
|
if (key !== 8 && key !== 46) {
|
|
return;
|
|
}
|
|
const selection = editor.selection;
|
|
if (!selection || !selection.isCollapsed()) {
|
|
return;
|
|
}
|
|
|
|
const body = editor.getBody();
|
|
const block = selection.getNode();
|
|
const isBlank = (node: any): boolean =>
|
|
!!node && node.nodeType === 1 && (node.textContent || '').trim() === '' &&
|
|
!editor.dom.getParent(node, 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder', body);
|
|
const isBlockElement = (node: any): boolean =>
|
|
!!node && node.nodeType === 1 && !!(node.matches?.('table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder'));
|
|
|
|
// 光标可能落在空段落里,也可能落在块级元素的边界(TinyMCE 常见行为);
|
|
// 两种情况下都优先删除紧邻的块,而不是让按键悄悄失效。
|
|
const blankContainer = isBlank(block) ? block : null;
|
|
const direct = blankContainer
|
|
? (key === 8 ? blankContainer.previousElementSibling : blankContainer.nextElementSibling)
|
|
: null;
|
|
|
|
if (isBlockElement(direct)) {
|
|
event.preventDefault();
|
|
editor.dom.remove(direct);
|
|
editor.nodeChanged();
|
|
editor.undoManager.add();
|
|
return;
|
|
}
|
|
|
|
// 光标停在块首/块尾时,删除块本身。
|
|
const caretBlock = editor.dom.getParent(block, 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder', body);
|
|
if (!caretBlock) {
|
|
return;
|
|
}
|
|
const caretIsAtEdge = (start: boolean): boolean => {
|
|
const probe = editor.dom.createRng();
|
|
probe.selectNodeContents(caretBlock);
|
|
probe.collapse(start);
|
|
const caret = selection.getRng();
|
|
return caret && typeof caret.compareBoundaryPoints === 'function'
|
|
&& caret.compareBoundaryPoints(start ? 1 : 3, probe) === 0;
|
|
};
|
|
|
|
if (!((key === 8 && caretIsAtEdge(true)) || (key === 46 && caretIsAtEdge(false)))) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const parent = caretBlock.parentElement;
|
|
editor.dom.remove(caretBlock);
|
|
// 删掉最后一个块后补一个空段落,否则没有可放置光标的位置。
|
|
if (parent && parent.children.length === 0) {
|
|
editor.setContent('<p></p>');
|
|
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
|
|
}
|
|
editor.nodeChanged();
|
|
editor.undoManager.add();
|
|
}
|
|
|
|
/**
|
|
* 在编辑器里为每个代码块渲染语言角标,并保证回车不会跳出代码区。
|
|
*
|
|
* 角标是编辑期提示(纯 UI),保存前由 serializeBlocks 移除。
|
|
*/
|
|
/**
|
|
* 在代码块内回车时保持在代码里换行,而不是跳出到普通段落。
|
|
*
|
|
* TinyMCE 默认把 pre 当成不可持续输入的块,回车会新建段落,导致代码跑到
|
|
* 代码块外面。这里手动插入换行并保持光标在 code 内。
|
|
*/
|
|
/**
|
|
* 让 iframe 高度跟随正文增长,避免编辑器内部出现第二根滚动条。
|
|
*
|
|
* TinyMCE 的 iframe 不会自动撑高;这里测量内容高度并同步容器,
|
|
* 页面整体滚动由外层工作台负责。
|
|
*/
|
|
function syncEditorHeight(editor: any): void {
|
|
if (!editor || editor.removed) {
|
|
return;
|
|
}
|
|
const doc = editor.getDoc();
|
|
const iframe = editor.getContentAreaContainer()?.querySelector('iframe') as HTMLIFrameElement | null;
|
|
if (!doc || !iframe) {
|
|
return;
|
|
}
|
|
const height = Math.max(
|
|
320,
|
|
doc.documentElement.scrollHeight,
|
|
doc.body ? doc.body.scrollHeight : 0
|
|
);
|
|
iframe.style.height = `${height}px`;
|
|
iframe.style.overflow = 'hidden';
|
|
}
|
|
|
|
function keepCaretInsideCode(editor: any, event: any): boolean {
|
|
if (Number(event.keyCode) !== 13 || event.shiftKey) {
|
|
return false;
|
|
}
|
|
const pre = editor.dom.getParent(editor.selection.getNode(), 'pre.enlighter-pre');
|
|
if (!pre) {
|
|
return false;
|
|
}
|
|
event.preventDefault();
|
|
const code = pre.querySelector('code') || pre;
|
|
editor.selection.setCursorLocation(code, code.childNodes.length);
|
|
editor.insertContent('<br>');
|
|
editor.nodeChanged();
|
|
return true;
|
|
}
|
|
|
|
/** 可整体删除的块级元素选择器。 */
|
|
const BLOCK_SELECTOR = 'table,pre.enlighter-pre,hr,.quote_q,p';
|
|
|
|
/**
|
|
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
|
|
*
|
|
* 与 WPS 的块操作一致:鼠标悬停到块上时在右上角出现删除入口,
|
|
* 避免只能靠反复按退格键。
|
|
*/
|
|
function installBlockActions(editor: any): void {
|
|
const doc = editor.getDoc();
|
|
if (!doc || doc.getElementById('jio-block-actions')) {
|
|
return;
|
|
}
|
|
const bar = doc.createElement('div');
|
|
bar.id = 'jio-block-actions';
|
|
bar.className = 'jio-creator__block-actions';
|
|
bar.innerHTML =
|
|
'<button type="button" class="jio-creator__block-action" data-jio-block-delete title="' +
|
|
'删除此块' +
|
|
'" aria-label="删除此块"><span aria-hidden="true">✕</span></button>';
|
|
doc.body.appendChild(bar);
|
|
|
|
let current: HTMLElement | null = null;
|
|
|
|
const hide = () => {
|
|
current = null;
|
|
bar.classList.remove('is-visible');
|
|
};
|
|
|
|
const show = (block: HTMLElement) => {
|
|
if (!block || block === doc.body) {
|
|
hide();
|
|
return;
|
|
}
|
|
// 空段落没有删除价值,交给退格键处理。
|
|
if (block.tagName === 'P' && (block.textContent || '').trim() === '') {
|
|
hide();
|
|
return;
|
|
}
|
|
current = block;
|
|
const rect = block.getBoundingClientRect();
|
|
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
|
|
bar.style.left = `${rect.right - 40}px`;
|
|
bar.classList.add('is-visible');
|
|
};
|
|
|
|
doc.addEventListener('mouseover', (event: MouseEvent) => {
|
|
const block = (event.target as HTMLElement)?.closest?.(BLOCK_SELECTOR) as HTMLElement | null;
|
|
if (block && block !== doc.body && !block.classList.contains('jio-code-lang')) {
|
|
show(block);
|
|
}
|
|
});
|
|
|
|
doc.addEventListener('mouseleave', hide);
|
|
|
|
bar.addEventListener('mousedown', (event: MouseEvent) => event.preventDefault());
|
|
bar.addEventListener('click', (event: MouseEvent) => {
|
|
const button = (event.target as HTMLElement)?.closest('[data-jio-block-delete]');
|
|
if (!button || !current) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const target = current;
|
|
hide();
|
|
editor.undoManager.transact(() => {
|
|
editor.dom.remove(target);
|
|
});
|
|
editor.nodeChanged();
|
|
editor.undoManager.add();
|
|
if (editor.getBody().children.length === 0) {
|
|
editor.setContent('<p></p>');
|
|
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
|
|
}
|
|
syncEditorHeight(editor);
|
|
});
|
|
}
|
|
|
|
function syncCodeLabels(editor: any): void {
|
|
const root: HTMLElement | null = editor?.getBody?.() ?? null;
|
|
if (!root) {
|
|
return;
|
|
}
|
|
root.querySelectorAll<HTMLElement>('pre.enlighter-pre[data-jio-code]').forEach((pre) => {
|
|
const existing = pre.querySelector(':scope > .jio-code-lang');
|
|
const language = pre.getAttribute('data-enlighter-language') || 'generic';
|
|
if (existing) {
|
|
existing.setAttribute('data-jio-code-lang', language);
|
|
const label = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
|
if ((existing.textContent || '') !== label) {
|
|
existing.textContent = label;
|
|
}
|
|
return;
|
|
}
|
|
const badge = document.createElement('span');
|
|
badge.className = 'jio-code-lang';
|
|
badge.contentEditable = 'false';
|
|
badge.setAttribute('data-jio-code-lang', language);
|
|
badge.textContent = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
|
pre.insertBefore(badge, pre.firstChild);
|
|
});
|
|
}
|
|
|
|
function escapeAttribute(value: string): string {
|
|
return value.replace(/&/g, '&').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 draftRef = useRef(draft);
|
|
draftRef.current = draft;
|
|
const editorRef = useRef<any>(null);
|
|
const editorBookmarkRef = useRef<any>(null);
|
|
const pendingRestoreContentRef = useRef<string | null>(null);
|
|
const editorReadyRef = useRef(false);
|
|
const saveActionRef = useRef<() => void>(() => undefined);
|
|
const [editorReady, setEditorReady] = useState(false);
|
|
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
|
const [formatValue, setFormatValue] = useState('p');
|
|
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
|
|
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
|
|
at: '',
|
|
}));
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [blockDialog, setBlockDialog] = useState<BlockDialogKind | null>(null);
|
|
const [codeLanguage, setCodeLanguage] = useState<null | string>(null);
|
|
const codeTargetRef = useRef<any>(null);
|
|
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
|
|
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
|
|
const dirtyRef = useRef(false);
|
|
const editRevisionRef = useRef(0);
|
|
|
|
const capabilities = bootstrap.capabilities || {};
|
|
const limits: Limits = {
|
|
titleMin: Number(bootstrap.limits?.titleMin || 1),
|
|
titleMax: Number(bootstrap.limits?.titleMax || 30),
|
|
titlePlaceholder: String(bootstrap.limits?.titlePlaceholder || __('请输入标题', 'jio')),
|
|
priceMin: Number(bootstrap.pay?.priceMin || 0),
|
|
priceMax: Number(bootstrap.pay?.priceMax || 0),
|
|
pointsMin: Number(bootstrap.pay?.pointsMin || 0),
|
|
pointsMax: Number(bootstrap.pay?.pointsMax || 0),
|
|
};
|
|
const isPublishedEdit = !!bootstrap.isPublished;
|
|
const charCount = useMemo(() => Array.from(plainText(draft.content)).length, [draft.content]);
|
|
|
|
const patchDraft = useCallback((patch: Partial<Draft>) => {
|
|
dirtyRef.current = true;
|
|
editRevisionRef.current += 1;
|
|
const next = { ...draftRef.current, ...patch };
|
|
draftRef.current = next;
|
|
setDraft(next);
|
|
}, []);
|
|
|
|
const updateContent = useCallback((content: string) => {
|
|
if (content === draftRef.current.content) {
|
|
return;
|
|
}
|
|
dirtyRef.current = true;
|
|
editRevisionRef.current += 1;
|
|
const next = { ...draftRef.current, content };
|
|
draftRef.current = next;
|
|
setDraft(next);
|
|
}, []);
|
|
|
|
const updateHistoryAvailability = useCallback((editor: any) => {
|
|
const manager = editor?.undoManager;
|
|
if (!manager) return;
|
|
setHistoryAvailability({
|
|
canUndo: typeof manager.hasUndo === 'function' && manager.hasUndo(),
|
|
canRedo: typeof manager.hasRedo === 'function' && manager.hasRedo(),
|
|
});
|
|
}, []);
|
|
|
|
const updateFormatValue = useCallback((editor: any) => {
|
|
const node = editor?.selection?.getNode();
|
|
const block = node ? editor.dom.getParent(node, 'p,h1,h2,h3,blockquote') || node : null;
|
|
const name = block?.nodeName?.toLowerCase() || 'p';
|
|
setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : 'p');
|
|
}, []);
|
|
|
|
const buildPayload = useCallback((extra: Record<string, any> = {}) => {
|
|
const current = draftRef.current;
|
|
return {
|
|
post_id: current.postId,
|
|
version_id: current.versionId,
|
|
rev: current.rev,
|
|
title: current.title,
|
|
content: current.content,
|
|
categories: current.categories,
|
|
tags: current.tags,
|
|
custom: current.custom,
|
|
featured: current.featured,
|
|
schedule: {
|
|
enabled: !!current.schedule.enabled,
|
|
at: current.schedule.enabled ? current.schedule.at : '',
|
|
},
|
|
pay: normalizePayForRequest(current.pay),
|
|
...extra,
|
|
};
|
|
}, []);
|
|
|
|
const save = useCallback(
|
|
async (autosave = false): Promise<boolean> => {
|
|
const editor = editorRef.current;
|
|
if (editor && !editor.removed) {
|
|
updateContent(serializeBlocks(editor.getContent()));
|
|
}
|
|
const current = draftRef.current;
|
|
if (!dirtyRef.current && (current.postId > 0 || current.versionId > 0)) {
|
|
return true;
|
|
}
|
|
const requestRevision = editRevisionRef.current;
|
|
const hasDocument = current.title.trim() !== '' || plainText(current.content).trim() !== '';
|
|
if (autosave && !hasDocument) {
|
|
return true;
|
|
}
|
|
setSaveState({ state: 'saving', at: '' });
|
|
try {
|
|
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
|
|
const updated = {
|
|
...draftRef.current,
|
|
postId: Number(response.post_id || draftRef.current.postId || 0),
|
|
versionId: Number(response.version_id || draftRef.current.versionId || 0),
|
|
rev: Number(response.rev || response.versionRev || draftRef.current.rev || 0),
|
|
};
|
|
if ((window as any).jioSubmitData) {
|
|
(window as any).jioSubmitData.postId = updated.postId;
|
|
}
|
|
const settled = editRevisionRef.current === requestRevision;
|
|
dirtyRef.current = !settled;
|
|
draftRef.current = updated;
|
|
setDraft(updated);
|
|
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleTimeString() : '' });
|
|
return settled;
|
|
} catch (error: any) {
|
|
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
|
|
return false;
|
|
}
|
|
},
|
|
[buildPayload]
|
|
);
|
|
saveActionRef.current = () => void save(false);
|
|
|
|
const submit = useCallback(
|
|
async (publishNow: boolean): Promise<void> => {
|
|
const persisted = await save(false);
|
|
if (!persisted) {
|
|
setNotice({ tone: 'error', text: __('内容保存失败,请重试后再提交', 'jio') });
|
|
return;
|
|
}
|
|
setSaveState({ state: 'submitting', at: '' });
|
|
try {
|
|
let captchaProof = '';
|
|
const captcha = (window as any).JioCaptcha;
|
|
if (bootstrap.captcha?.enabled && captcha && typeof captcha.run === 'function') {
|
|
const payload = await captcha.run('front_submit');
|
|
if (payload === null) {
|
|
setSaveState({ state: 'ready', at: '' });
|
|
setNotice({ tone: 'error', text: __('请先完成人机验证', 'jio') });
|
|
return;
|
|
}
|
|
captchaProof = String(payload?.jio_captcha_proof || '');
|
|
}
|
|
const response = await apiPost(
|
|
bootstrap.creator.submitUrl,
|
|
buildPayload({
|
|
post_id: draftRef.current.postId,
|
|
version_id: draftRef.current.versionId,
|
|
rev: draftRef.current.rev,
|
|
publish_now: publishNow,
|
|
captcha_proof: captchaProof,
|
|
}),
|
|
nonce
|
|
);
|
|
dirtyRef.current = false;
|
|
setSaveState({ state: 'submitted', at: '' });
|
|
setNotice({ tone: 'ok', text: String(response.message || __('已提交', 'jio')) });
|
|
setDrawerOpen(false);
|
|
const target = response.url || response.goto || bootstrap.urls?.center;
|
|
if (target) {
|
|
window.setTimeout(() => {
|
|
window.location.href = target;
|
|
}, 900);
|
|
}
|
|
} catch (error: any) {
|
|
setSaveState({ state: 'error', at: '' });
|
|
setNotice({ tone: 'error', text: String(error?.message || __('提交失败', 'jio')) });
|
|
}
|
|
},
|
|
[buildPayload, save]
|
|
);
|
|
|
|
const preview = useCallback(async (): Promise<void> => {
|
|
const saved = await save(false);
|
|
if (!saved) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await apiPost(
|
|
bootstrap.creator.previewUrl,
|
|
{ post_id: draftRef.current.postId, version_id: draftRef.current.versionId },
|
|
nonce
|
|
);
|
|
if (response.url) {
|
|
window.open(String(response.url), '_blank', 'noopener');
|
|
}
|
|
} catch (error: any) {
|
|
setNotice({ tone: 'error', text: String(error?.message || __('预览失败', 'jio')) });
|
|
}
|
|
}, [save]);
|
|
|
|
const { markLocalSaved, clear } = useLocalRecovery({
|
|
key: STORAGE_KEY,
|
|
snapshot: { title: draft.title, content: draft.content, at: new Date().toISOString() },
|
|
onFound: (savedAt: string) => setRecovery({ savedAt }),
|
|
});
|
|
|
|
const autosave = useAutosave({
|
|
enabled: !!capabilities.isLoggedIn,
|
|
onSave: async () => {
|
|
const ok = await save(true);
|
|
if (ok) {
|
|
clear();
|
|
}
|
|
return ok;
|
|
},
|
|
onLocal: markLocalSaved,
|
|
});
|
|
|
|
const autosaveSchedule = autosave.schedule;
|
|
|
|
useEffect(() => {
|
|
if (dirtyRef.current) {
|
|
autosaveSchedule();
|
|
}
|
|
}, [draft, autosaveSchedule]);
|
|
|
|
useEffect(() => {
|
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!dirtyRef.current) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
};
|
|
window.addEventListener('beforeunload', beforeUnload);
|
|
return () => window.removeEventListener('beforeunload', beforeUnload);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
|
event.preventDefault();
|
|
void save(false);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [save]);
|
|
|
|
useEffect(() => {
|
|
const wpEditor = (window as any).wp?.editor;
|
|
if (!wpEditor || typeof wpEditor.initialize !== 'function') {
|
|
setNotice({ tone: 'error', text: __('TinyMCE 加载失败,请刷新页面后重试。', 'jio') });
|
|
return undefined;
|
|
}
|
|
|
|
const editorSettings: Record<string, any> = {
|
|
quicktags: false,
|
|
tinymce: {
|
|
menubar: false,
|
|
statusbar: false,
|
|
resize: false,
|
|
toolbar1: '',
|
|
toolbar2: '',
|
|
toolbar3: '',
|
|
plugins: 'paste,link,lists,wordpress,wplink',
|
|
wpautop: true,
|
|
body_class: 'jio-creator__tinymce',
|
|
content_css: bootstrap.creator?.contentCss || '',
|
|
content_style: EDITOR_CONTENT_STYLE,
|
|
// iframe 高度随后由 autoresize 随正文增长,这里只给初始值。
|
|
height: 520,
|
|
placeholder: String(bootstrap.limits?.contentPlaceholder || ''),
|
|
setup: (editor: any) => {
|
|
editorRef.current = editor;
|
|
editor.on('init', () => {
|
|
editorReadyRef.current = true;
|
|
setEditorReady(true);
|
|
if (pendingRestoreContentRef.current !== null) {
|
|
editor.setContent(hydrateBlocks(pendingRestoreContentRef.current));
|
|
pendingRestoreContentRef.current = null;
|
|
} else {
|
|
editor.setContent(hydrateBlocks(editor.getContent()));
|
|
}
|
|
syncCodeLabels(editor);
|
|
syncEditorHeight(editor);
|
|
installBlockActions(editor);
|
|
updateHistoryAvailability(editor);
|
|
updateFormatValue(editor);
|
|
});
|
|
editor.on('NodeChange', () => updateFormatValue(editor));
|
|
// 代码块右上角的语言标签:点击整块即可换语言,等价于公众号的「高亮」入口。
|
|
editor.on('input change Undo Redo AddUndo', () => {
|
|
updateContent(serializeBlocks(editor.getContent()));
|
|
updateHistoryAvailability(editor);
|
|
syncCodeLabels(editor);
|
|
syncEditorHeight(editor);
|
|
});
|
|
editor.on('keydown', (event: any) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.keyCode === 83) {
|
|
event.preventDefault();
|
|
saveActionRef.current();
|
|
return;
|
|
}
|
|
removeAdjacentBlock(editor, event);
|
|
});
|
|
},
|
|
},
|
|
};
|
|
wpEditor.initialize(EDITOR_ID, editorSettings);
|
|
|
|
return () => {
|
|
wpEditor.remove?.(EDITOR_ID);
|
|
editorReadyRef.current = false;
|
|
editorRef.current = null;
|
|
};
|
|
}, [updateContent]);
|
|
|
|
const restore = useCallback(() => {
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
const patch: Partial<Draft> = {};
|
|
if (typeof parsed.title === 'string') patch.title = parsed.title;
|
|
if (typeof parsed.content === 'string') patch.content = parsed.content;
|
|
patchDraft(patch);
|
|
if (typeof parsed.content === 'string') {
|
|
if (editorReadyRef.current) {
|
|
editorRef.current?.setContent(parsed.content);
|
|
} else {
|
|
pendingRestoreContentRef.current = parsed.content;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// 快照损坏时忽略,不影响服务端内容。
|
|
}
|
|
setRecovery(null);
|
|
}, [patchDraft]);
|
|
|
|
const rememberSelection = useCallback(() => {
|
|
const editor = editorRef.current;
|
|
if (editor && !editor.removed) {
|
|
editorBookmarkRef.current = editor.selection.getBookmark(2, true);
|
|
}
|
|
}, []);
|
|
|
|
const focusEditor = useCallback(() => {
|
|
const editor = editorRef.current;
|
|
if (!editor || editor.removed) {
|
|
return null;
|
|
}
|
|
editor.focus();
|
|
if (editorBookmarkRef.current) {
|
|
try {
|
|
editor.selection.moveToBookmark(editorBookmarkRef.current);
|
|
} catch (error) {
|
|
// TinyMCE discards invalid bookmarks after content replacement.
|
|
}
|
|
editorBookmarkRef.current = null;
|
|
}
|
|
return editor;
|
|
}, []);
|
|
|
|
const command = useCallback((name: string, value?: string) => {
|
|
const editor = focusEditor();
|
|
if (!editor) {
|
|
return;
|
|
}
|
|
if (name === 'undo') {
|
|
editor.undoManager.undo();
|
|
} else if (name === 'redo') {
|
|
editor.undoManager.redo();
|
|
} else if (name === 'format') {
|
|
if (value === 'p') {
|
|
editor.execCommand('FormatBlock', false, 'p');
|
|
} else {
|
|
editor.formatter.toggle(value || 'h2');
|
|
}
|
|
updateFormatValue(editor);
|
|
} else {
|
|
editor.execCommand(name);
|
|
}
|
|
editor.nodeChanged();
|
|
}, [focusEditor]);
|
|
|
|
const openLink = useCallback(() => {
|
|
const editor = focusEditor();
|
|
if (!editor) {
|
|
return;
|
|
}
|
|
const wpLink = (window as any).wpLink;
|
|
if (wpLink && typeof wpLink.open === 'function') {
|
|
wpLink.open(editor.id);
|
|
return;
|
|
}
|
|
const url = window.prompt(__('输入链接地址', 'jio'), 'https://');
|
|
const safeUrl = url ? safeHttpUrl(url) : '';
|
|
if (safeUrl) {
|
|
editor.execCommand('mceInsertLink', false, { href: safeUrl });
|
|
}
|
|
}, [focusEditor]);
|
|
|
|
const insertHtml = useCallback((html: string) => {
|
|
const editor = focusEditor();
|
|
if (!editor || !html) {
|
|
return;
|
|
}
|
|
editor.insertContent(html);
|
|
editor.nodeChanged();
|
|
updateContent(serializeBlocks(editor.getContent()));
|
|
updateHistoryAvailability(editor);
|
|
syncCodeLabels(editor);
|
|
}, [focusEditor]);
|
|
|
|
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
|
|
const insertBlock = useCallback((kind: string) => {
|
|
rememberSelection();
|
|
// code 走「语言选择」弹窗,其余三类走通用插入弹窗。
|
|
if (kind === 'table' || kind === 'math' || kind === 'footnote') {
|
|
setBlockDialog(kind as BlockDialogKind);
|
|
return;
|
|
}
|
|
if (kind === 'code') {
|
|
// 直接插入;语言可稍后点代码块右上角的角标更换。
|
|
insertHtml(codeBlockMarkup('generic'));
|
|
return;
|
|
}
|
|
if (kind === 'list') {
|
|
insertHtml(listMarkup(false));
|
|
return;
|
|
}
|
|
if (kind === 'toc') {
|
|
insertHtml(tocMarkup());
|
|
return;
|
|
}
|
|
if (kind === 'divider') {
|
|
insertHtml(dividerMarkup());
|
|
}
|
|
}, [insertHtml, rememberSelection]);
|
|
|
|
const insertQuote = useCallback((colorClass: QuoteColor) => {
|
|
const editor = focusEditor();
|
|
if (!editor) {
|
|
return;
|
|
}
|
|
const selected = String(editor.selection.getContent() || '').trim();
|
|
const existing = editor.dom.getParent(editor.selection.getNode(), '.quote_q');
|
|
if (existing) {
|
|
existing.className = `quote_q quote-mce${colorClass ? ` ${colorClass}` : ''}`;
|
|
editor.nodeChanged();
|
|
return;
|
|
}
|
|
insertHtml(quoteMarkup(selected, colorClass));
|
|
}, [focusEditor, insertHtml]);
|
|
|
|
const applyBlockDialog = useCallback((result: BlockDialogResult) => {
|
|
setBlockDialog(null);
|
|
// 弹窗抢走焦点后,插入前把光标放回正文的原选区。
|
|
if (result.kind === 'table') {
|
|
insertHtml(tableMarkup(result));
|
|
} else if (result.kind === 'math') {
|
|
insertHtml(mathMarkup(result));
|
|
} else {
|
|
insertHtml(footnoteMarkup(result.note));
|
|
}
|
|
}, [insertHtml]);
|
|
|
|
const openMedia = useCallback((type: 'image' | 'video' | 'file') => {
|
|
const editor = editorRef.current;
|
|
const media = (window as any).JioSubmitMedia;
|
|
if (!editor || !media || typeof media.open !== 'function') {
|
|
setNotice({ tone: 'error', text: __('媒体工具尚未加载,请刷新页面后重试。', 'jio') });
|
|
return;
|
|
}
|
|
rememberSelection();
|
|
media.open({
|
|
type,
|
|
multiple: type === 'image' ? undefined : false,
|
|
onSelect: (selected: any) => {
|
|
const items = Array.isArray(selected) ? selected : [selected];
|
|
const target = focusEditor();
|
|
if (!target) return;
|
|
const markup = items.map((item) => {
|
|
const url = safeHttpUrl(item?.large_url || item?.url);
|
|
if (!url) return '';
|
|
const id = Number(item.attachment_id || item.id || 0);
|
|
const idAttribute = id > 0 ? ` data-edit-file-id="${id}"` : '';
|
|
if (type === 'image') {
|
|
const fullUrl = safeHttpUrl(item?.url || url);
|
|
const fullAttribute = fullUrl ? ` data-full-url="${escapeAttribute(fullUrl)}"` : '';
|
|
return `<p><img src="${escapeAttribute(url)}"${idAttribute} alt="${escapeAttribute(String(item.name || item.title || ''))}"${fullAttribute}></p>`;
|
|
}
|
|
if (type === 'file') {
|
|
const name = String(item.filename || item.name || url.split('/').pop() || '');
|
|
return `<p><a class="jio-file-link" href="${escapeAttribute(url)}"${idAttribute}><i class="fa fas fa-paperclip" aria-hidden="true"></i> ${escapeAttribute(name)}</a></p>`;
|
|
}
|
|
return `<div contenteditable="false" data-video-url="${escapeAttribute(url)}" data-video-name="${escapeAttribute(String(item.filename || item.name || url))}"${idAttribute} class="new-dplayer post-dplayer dplayer"></div><p></p>`;
|
|
}).join('');
|
|
if (markup) {
|
|
target.insertContent(markup);
|
|
target.nodeChanged();
|
|
updateContent(target.getContent());
|
|
updateHistoryAvailability(target);
|
|
}
|
|
},
|
|
onEmbed: (html: string) => {
|
|
const target = focusEditor();
|
|
if (target) {
|
|
target.insertContent(`${html}<p></p>`);
|
|
target.nodeChanged();
|
|
updateContent(target.getContent());
|
|
updateHistoryAvailability(target);
|
|
}
|
|
},
|
|
});
|
|
}, [focusEditor, rememberSelection]);
|
|
|
|
return (
|
|
<div className="jio-creator" data-status={bootstrap.status || ''} data-editor-ready={editorReady ? 'true' : 'false'}>
|
|
<header className="jio-creator__bar">
|
|
<div className="jio-creator__bar-left">
|
|
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'} aria-label={__('返回作者中心', 'jio')}>
|
|
<i className="fa fas fa-angle-left" aria-hidden="true" />
|
|
</a>
|
|
<span className="jio-creator__bar-brand">Jio</span>
|
|
<span className="jio-creator__bar-divider" aria-hidden="true" />
|
|
<span className="jio-creator__bar-mode">
|
|
{isPublishedEdit ? __('修改文章', 'jio') : __('写文章', 'jio')}
|
|
</span>
|
|
</div>
|
|
|
|
<EditorToolbar
|
|
ready={editorReady}
|
|
formatValue={formatValue}
|
|
onFormatChange={setFormatValue}
|
|
canUndo={historyAvailability.canUndo}
|
|
canRedo={historyAvailability.canRedo}
|
|
capabilities={capabilities}
|
|
onCommand={command}
|
|
onRememberSelection={rememberSelection}
|
|
onOpenLink={openLink}
|
|
onOpenMedia={openMedia}
|
|
onInsertBlock={insertBlock}
|
|
onInsertQuote={insertQuote}
|
|
/>
|
|
</header>
|
|
|
|
{recovery && (
|
|
<div className="jio-creator__notice" role="status">
|
|
<span>{__('发现本地未同步的草稿。', 'jio')}</span>
|
|
<button type="button" onClick={restore}>{__('恢复', 'jio')}</button>
|
|
<button type="button" onClick={() => setRecovery(null)}>{__('忽略', 'jio')}</button>
|
|
</div>
|
|
)}
|
|
|
|
{notice && (
|
|
<div className={`jio-creator__notice is-${notice.tone}`} role="status">
|
|
<span>{notice.text}</span>
|
|
<button type="button" onClick={() => setNotice(null)} aria-label={__('关闭', 'jio')}>
|
|
<i className="fa fas fa-times" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isPublishedEdit && (
|
|
<p className="jio-creator__hint">{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}</p>
|
|
)}
|
|
|
|
<main className="jio-creator__canvas">
|
|
<label className="jio-creator__title">
|
|
<span className="screen-reader-text">{__('文章标题', 'jio')}</span>
|
|
<textarea
|
|
value={draft.title}
|
|
onChange={(event) => patchDraft({ title: event.target.value })}
|
|
rows={1}
|
|
placeholder={sprintf(
|
|
/* translators: %d: max title length */
|
|
__('请输入标题(最多 %d 个字)', 'jio'),
|
|
Number(limits.titleMax) || 100
|
|
)}
|
|
spellCheck={false}
|
|
maxLength={Number(limits.titleMax) || 100}
|
|
/>
|
|
</label>
|
|
|
|
<div className="jio-creator__editor" data-editor-ready={editorReady ? 'true' : 'false'}>
|
|
<textarea id={EDITOR_ID} defaultValue={draft.content} onChange={(event) => updateContent(event.target.value)} aria-label={__('文章正文', 'jio')} />
|
|
</div>
|
|
</main>
|
|
|
|
<PublishSettings
|
|
featured={draft.featured}
|
|
canEditCover={!!capabilities.canUploadImage}
|
|
canSchedule={!!capabilities.canSchedule}
|
|
scheduleEnabled={!!draft.schedule.enabled}
|
|
scheduleAt={draft.schedule.at}
|
|
onFeaturedChange={(featured) => patchDraft({ featured })}
|
|
onScheduleChange={(enabled, at) => patchDraft({ schedule: { enabled, at } })}
|
|
/>
|
|
|
|
{codeLanguage !== null && (
|
|
<div className="jio-creator-dialog" role="dialog" aria-modal="true" aria-labelledby="jio-code-lang-title">
|
|
<button type="button" className="jio-creator-dialog__backdrop" aria-label={__('关闭', 'jio')} onClick={() => setCodeLanguage(null)} />
|
|
<div className="jio-creator-dialog__panel">
|
|
<header className="jio-creator-dialog__head">
|
|
<h2 id="jio-code-lang-title">{__('代码语言', 'jio')}</h2>
|
|
<button type="button" className="jio-creator-dialog__close" onClick={() => setCodeLanguage(null)} aria-label={__('关闭', 'jio')}>
|
|
<i className="fa fas fa-times" aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
<div className="jio-creator-dialog__body">
|
|
<div className="jio-creator__lang-grid">
|
|
{CODE_LANGUAGES.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
className={`jio-creator__lang-item${item.value === codeLanguage ? ' is-active' : ''}`}
|
|
onClick={() => {
|
|
const target = codeTargetRef.current;
|
|
codeTargetRef.current = null;
|
|
setCodeLanguage(null);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
target.setAttribute('data-enlighter-language', item.value);
|
|
const code = target.querySelector('code');
|
|
if (code) {
|
|
code.setAttribute('data-enlighter-language', item.value);
|
|
}
|
|
syncCodeLabels(editorRef.current);
|
|
updateContent(serializeBlocks(editorRef.current.getContent()));
|
|
}}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{blockDialog && (
|
|
<BlockDialog
|
|
kind={blockDialog}
|
|
onSubmit={applyBlockDialog}
|
|
onClose={() => setBlockDialog(null)}
|
|
/>
|
|
)}
|
|
|
|
<StatusBar
|
|
charCount={charCount}
|
|
saveState={saveState}
|
|
isPublishedEdit={isPublishedEdit}
|
|
saving={!editorReady || saveState.state === 'saving' || saveState.state === 'submitting'}
|
|
onSaveDraft={() => void save(false)}
|
|
onPreview={() => void preview()}
|
|
onPublish={() => setDrawerOpen(true)}
|
|
/>
|
|
|
|
{drawerOpen && (
|
|
<PublishDrawer
|
|
draft={draft}
|
|
setDraft={patchDraft}
|
|
capabilities={capabilities}
|
|
limits={limits}
|
|
taxonomy={{
|
|
categories: (bootstrap.categories && bootstrap.categories.items) || [],
|
|
tags: !!bootstrap.tags?.enabled,
|
|
customDefs: bootstrap.customDefs || [],
|
|
}}
|
|
isPublishedEdit={isPublishedEdit}
|
|
onClose={() => setDrawerOpen(false)}
|
|
onSaveDraft={() => void save(false)}
|
|
onSubmit={(publishNow: boolean) => void submit(publishNow)}
|
|
privateStorage={!!bootstrap.privateStorage}
|
|
privateUploads={bootstrap.privateUploads || []}
|
|
captcha={!!bootstrap.captcha?.enabled}
|
|
saving={saveState.state === 'saving' || saveState.state === 'submitting'}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function mount() {
|
|
const target = document.getElementById('jio-creator-studio');
|
|
if (!target) {
|
|
return;
|
|
}
|
|
target.classList.add('is-ready');
|
|
createRoot(target).render(<Studio />);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', mount);
|
|
} else {
|
|
mount();
|
|
}
|
|
|
|
export { Studio };
|