/** * 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 = 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; mediaId?: number }; type PayState = { enabled: boolean; modo: 'cash' | 'points'; price: string; points: number; vip1Price: string; vip2Price: string; payLimit: number; downloads: Array>; }; 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; 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('

'); 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('
'); 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 = ''; 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('

'); 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('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, '>'); } 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); 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 [saveState, setSaveState] = useState(() => ({ state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready', at: '', })); const [drawerOpen, setDrawerOpen] = useState(false); const [blockDialog, setBlockDialog] = useState(null); const [codeLanguage, setCodeLanguage] = useState(null); const codeTargetRef = useRef(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,blockquote') || node : null; const name = block?.nodeName?.toLowerCase() || 'p'; setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : '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() !== ''; 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 => { 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 => { 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())); } 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 = {}; 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 `

${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 (