/** * 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 = 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, '>'); } 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(() => initialDraft(bootstrap)); const draftRef = useRef(draft); draftRef.current = draft; const editorRef = useRef(null); const editorBookmarkRef = useRef(null); const pendingRestoreContentRef = useRef(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(() => ({ state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready', at: '', })); const [drawerOpen, setDrawerOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false); const [blockDialog, setBlockDialog] = useState(null); const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null); const [recovery, setRecovery] = useState(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) => { 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 = {}) => { 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 => { 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 => { 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 => { 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 = { 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())); } 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 = {}; 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 `

${escapeAttribute(String(item.name || item.title || ''))}

`; } if (type === 'file') { const name = String(item.filename || item.name || url.split('/').pop() || ''); return `

${escapeAttribute(name)}

`; } return `

`; }).join(''); if (markup) { target.insertContent(markup); target.nodeChanged(); updateContent(target.getContent()); updateHistoryAvailability(target); } }, onEmbed: (html: string) => { const target = focusEditor(); if (target) { target.insertContent(`${html}

`); target.nodeChanged(); updateContent(target.getContent()); updateHistoryAvailability(target); } }, }); }, [focusEditor, rememberSelection]); return (
{recovery && (
{__('发现本地未同步的草稿。', 'jio')}
)} {notice && (
{notice.text}