feat(creator-studio): switch writing editor to TinyMCE
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4868de2cda
commit
384d2b7aa0
25 files changed
+1255
-7076
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
type Capabilities = Record<string, boolean>;
|
||||
|
||||
type Props = {
|
||||
ready: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
formatValue: string;
|
||||
onFormatChange: (value: string) => void;
|
||||
capabilities: Capabilities;
|
||||
onCommand: (name: string, value?: string) => void;
|
||||
onRememberSelection: () => void;
|
||||
onOpenLink: () => void;
|
||||
onOpenMedia: (type: 'image' | 'video') => void;
|
||||
};
|
||||
|
||||
export function EditorToolbar({
|
||||
ready,
|
||||
canUndo,
|
||||
canRedo,
|
||||
formatValue,
|
||||
onFormatChange,
|
||||
capabilities,
|
||||
onCommand,
|
||||
onRememberSelection,
|
||||
onOpenLink,
|
||||
onOpenMedia,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className="jio-creator__toolbar-tools"
|
||||
role="toolbar"
|
||||
aria-label={__('正文编辑工具', 'jio')}
|
||||
onMouseDown={(event) => {
|
||||
onRememberSelection();
|
||||
if ((event.target as HTMLElement).closest('button')) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="jio-creator__toolbar-group" role="group" aria-label={__('撤销和重做', 'jio')}>
|
||||
<button type="button" className="jio-creator__tool-btn" disabled={!canUndo} onClick={() => onCommand('undo')} title={__('撤销', 'jio')} aria-label={__('撤销', 'jio')}>
|
||||
<i className="fa fas fa-undo" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="jio-creator__tool-btn" disabled={!canRedo} onClick={() => onCommand('redo')} title={__('重做', 'jio')} aria-label={__('重做', 'jio')}>
|
||||
<i className="fa fas fa-redo" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
<select
|
||||
className="jio-creator__format-select"
|
||||
aria-label={__('段落样式', 'jio')}
|
||||
value={formatValue}
|
||||
disabled={!ready}
|
||||
onFocus={onRememberSelection}
|
||||
onChange={(event) => {
|
||||
onFormatChange(event.target.value);
|
||||
onCommand('format', event.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="p">{__('正文', 'jio')}</option>
|
||||
<option value="h1">{__('标题 1', 'jio')}</option>
|
||||
<option value="h2">{__('标题 2', 'jio')}</option>
|
||||
<option value="h3">{__('标题 3', 'jio')}</option>
|
||||
<option value="blockquote">{__('引用', 'jio')}</option>
|
||||
</select>
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
<div className="jio-creator__toolbar-group" role="group" aria-label={__('文字格式', 'jio')}>
|
||||
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Bold')} title={__('加粗', 'jio')} aria-label={__('加粗', 'jio')}>
|
||||
<i className="fa fas fa-bold" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Italic')} title={__('斜体', 'jio')} aria-label={__('斜体', 'jio')}>
|
||||
<i className="fa fas fa-italic" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Underline')} title={__('下划线', 'jio')} aria-label={__('下划线', 'jio')}>
|
||||
<i className="fa fas fa-underline" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
<div className="jio-creator__toolbar-group" role="group" aria-label={__('媒体和链接', 'jio')}>
|
||||
{capabilities.canUploadImage && (
|
||||
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('image')} title={__('插入图片', 'jio')}>
|
||||
<i className="fa far fa-image" aria-hidden="true" /><span>{__('图片', 'jio')}</span>
|
||||
</button>
|
||||
)}
|
||||
{(capabilities.canUploadVideo || capabilities.canEmbed) && (
|
||||
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('video')} title={__('插入视频', 'jio')}>
|
||||
<i className="fa fas fa-video" aria-hidden="true" /><span>{__('视频', 'jio')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={onOpenLink} title={__('插入链接', 'jio')}>
|
||||
<i className="fa fas fa-link" aria-hidden="true" /><span>{__('链接', 'jio')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 图片宽度预设(公众号式四档)。
|
||||
*
|
||||
* 通过 block filter 给 core/image 追加「半屏 / 中等 / 通栏 / 全宽」四档宽度,
|
||||
* 选中图片时出现在区块工具条里。宽度存进图片区块的 className,序列化到
|
||||
* post_content 后由前台样式生效,不需要自定义区块或额外元数据。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { addFilter } from '@wordpress/hooks';
|
||||
import { createHigherOrderComponent } from '@wordpress/compose';
|
||||
import { BlockControls } from '@wordpress/block-editor';
|
||||
import { ToolbarGroup } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
const WIDTH_CLASSES = [
|
||||
{ key: 'half', label: __('半屏', 'jio'), className: 'jio-img-w--half' },
|
||||
{ key: 'medium', label: __('中等', 'jio'), className: 'jio-img-w--medium' },
|
||||
{ key: 'wide', label: __('通栏', 'jio'), className: 'jio-img-w--wide' },
|
||||
{ key: 'full', label: __('全宽', 'jio'), className: 'jio-img-w--full' },
|
||||
];
|
||||
|
||||
function currentPreset(className: string): string {
|
||||
const found = WIDTH_CLASSES.find((w) => (className || '').split(/\s+/).includes(w.className));
|
||||
return found ? found.key : 'wide';
|
||||
}
|
||||
|
||||
function withImageWidthControls(BlockEdit: any) {
|
||||
return (props: any) => {
|
||||
if (props.name !== 'core/image') {
|
||||
return <BlockEdit {...props} />;
|
||||
}
|
||||
|
||||
const className = String(props.attributes?.className || '');
|
||||
const active = currentPreset(className);
|
||||
|
||||
const setPreset = (preset: (typeof WIDTH_CLASSES)[number]) => {
|
||||
const rest = className
|
||||
.split(/\s+/)
|
||||
.filter((c) => c && !WIDTH_CLASSES.some((w) => w.className === c));
|
||||
props.setAttributes({ className: [...rest, preset.className].join(' ') });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BlockEdit {...props} />
|
||||
<BlockControls group="inline">
|
||||
<ToolbarGroup
|
||||
controls={WIDTH_CLASSES.map((preset) => ({
|
||||
title: preset.label,
|
||||
// 用文字而不是图标,四档宽度靠标签更易分辨。
|
||||
children: preset.label,
|
||||
isActive: active === preset.key,
|
||||
onClick: () => setPreset(preset),
|
||||
}))}
|
||||
/>
|
||||
</BlockControls>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function installImageWidthPresets(): void {
|
||||
if ((window as any).__jioImageWidthInstalled) {
|
||||
return;
|
||||
}
|
||||
(window as any).__jioImageWidthInstalled = true;
|
||||
addFilter(
|
||||
'editor.BlockEdit',
|
||||
'jio/creator-studio/image-width',
|
||||
createHigherOrderComponent(withImageWidthControls, 'withJioImageWidth')
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* 工作台级撤销/重做历史。
|
||||
*
|
||||
* BlockEditorProvider 独立运行时不带 core/editor 的 post history store,
|
||||
* 因此为标题、摘要、区块树保存快照。输入以 450ms 合并成一条历史记录,
|
||||
* 避免每个字符都占用一步。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
|
||||
|
||||
type Snapshot<T> = { value: T; signature: string };
|
||||
type HistoryState<T> = { past: Snapshot<T>[]; future: Snapshot<T>[]; present: Snapshot<T>; pending: T | null };
|
||||
|
||||
type Options<T> = {
|
||||
initial: T;
|
||||
onRestore: (value: T) => void;
|
||||
limit?: number;
|
||||
mergeDelay?: number;
|
||||
};
|
||||
|
||||
function copy<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function snapshot<T>(value: T): Snapshot<T> {
|
||||
const cloned = copy(value);
|
||||
return { value: cloned, signature: JSON.stringify(cloned) };
|
||||
}
|
||||
|
||||
export function useEditorHistory<T>({ initial, onRestore, limit = 80, mergeDelay = 450 }: Options<T>) {
|
||||
const state = useRef<HistoryState<T>>({
|
||||
past: [],
|
||||
future: [],
|
||||
present: snapshot(initial),
|
||||
pending: null,
|
||||
});
|
||||
const timer = useRef<number | null>(null);
|
||||
const restoreRef = useRef(onRestore);
|
||||
restoreRef.current = onRestore;
|
||||
const [availability, setAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
|
||||
const updateAvailability = useCallback(() => {
|
||||
setAvailability({
|
||||
canUndo: state.current.past.length > 0 || state.current.pending !== null,
|
||||
canRedo: state.current.future.length > 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
const current = state.current;
|
||||
if (current.pending === null) {
|
||||
return;
|
||||
}
|
||||
const next = snapshot(current.pending);
|
||||
current.pending = null;
|
||||
if (next.signature === current.present.signature) {
|
||||
updateAvailability();
|
||||
return;
|
||||
}
|
||||
current.past = [...current.past, current.present].slice(-limit);
|
||||
current.present = next;
|
||||
current.future = [];
|
||||
updateAvailability();
|
||||
}, [limit, updateAvailability]);
|
||||
|
||||
const record = useCallback(
|
||||
(value: T) => {
|
||||
state.current.pending = copy(value);
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
}
|
||||
timer.current = window.setTimeout(flush, mergeDelay);
|
||||
updateAvailability();
|
||||
},
|
||||
[flush, mergeDelay, updateAvailability]
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
flush();
|
||||
const current = state.current;
|
||||
if (!current.past.length) {
|
||||
return false;
|
||||
}
|
||||
const previous = current.past[current.past.length - 1];
|
||||
current.past = current.past.slice(0, -1);
|
||||
current.future = [current.present, ...current.future].slice(0, limit);
|
||||
current.present = previous;
|
||||
restoreRef.current(copy(previous.value));
|
||||
updateAvailability();
|
||||
return true;
|
||||
}, [flush, limit, updateAvailability]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
flush();
|
||||
const current = state.current;
|
||||
if (!current.future.length) {
|
||||
return false;
|
||||
}
|
||||
const next = current.future[0];
|
||||
current.future = current.future.slice(1);
|
||||
current.past = [...current.past, current.present].slice(-limit);
|
||||
current.present = next;
|
||||
restoreRef.current(copy(next.value));
|
||||
updateAvailability();
|
||||
return true;
|
||||
}, [flush, limit, updateAvailability]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { ...availability, record, undo, redo, flush };
|
||||
}
|
||||
+297
-218
@@ -1,47 +1,30 @@
|
||||
/**
|
||||
* Jio Creator Studio — 前台沉浸式创作界面。
|
||||
* Jio Creator Studio — 前台 TinyMCE 写作工作台。
|
||||
*
|
||||
* Gutenberg 负责区块解析、编辑与序列化;本入口只搭建工作台外壳、发布配置抽屉
|
||||
* 与保存/预览/提交编排。所有写入都走 Jio REST(CreatorRest),服务端负责权限、
|
||||
* 字段清洗、审核状态与私有附件归属。
|
||||
* TinyMCE 负责文章正文编辑,固定操作栏通过 TinyMCE API 执行排版与媒体命令;
|
||||
* Jio REST 继续负责保存、审核、权限、付费配置与私有附件归属。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
|
||||
import { createBlock, parse, serialize } from '@wordpress/blocks';
|
||||
import { registerCoreBlocks } from '@wordpress/block-library';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { BlockEditorProvider, BlockList, BlockTools } from '@wordpress/block-editor';
|
||||
import { count as countWords } from '@wordpress/wordcount';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
import { apiPost } from './services/api';
|
||||
import { installApiFetchNonce } from './services/api-fetch';
|
||||
import { installMediaUploadBridge } from './services/media';
|
||||
import { installImageWidthPresets } from './extensions/image-width';
|
||||
import { PublishDrawer } from './panels/publish-drawer';
|
||||
import { CoverStrip } from './components/cover-strip';
|
||||
import { AuthorRow } from './components/author-row';
|
||||
import { InsertPanel, type InsertItem } from './panels/insert-panel';
|
||||
import { buildInsertGroups } from './utils/insert-items';
|
||||
import { EditorToolbar } from './components/editor-toolbar';
|
||||
import { SaveState, type SaveStateValue } from './components/save-state';
|
||||
import { useAutosave } from './hooks/use-autosave';
|
||||
import { useEditorHistory } from './hooks/use-editor-history';
|
||||
import { useLocalRecovery } from './hooks/use-local-recovery';
|
||||
import { normalizePayForRequest } from './utils/pay';
|
||||
import './styles/studio.scss';
|
||||
|
||||
// 核心区块注册只做一次;重复调用会触发 "Block type already registered"。
|
||||
const coreRegistered = (window as any).__jioCoreBlocksRegistered;
|
||||
if (!coreRegistered && typeof registerCoreBlocks === 'function') {
|
||||
registerCoreBlocks();
|
||||
(window as any).__jioCoreBlocksRegistered = true;
|
||||
}
|
||||
|
||||
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
|
||||
const nonce: string = bootstrap.nonce || '';
|
||||
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
|
||||
const EDITOR_ID = 'jio-creator-content';
|
||||
|
||||
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
|
||||
type PayState = {
|
||||
@@ -107,58 +90,50 @@ function initialDraft(): Draft {
|
||||
};
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function safeHttpUrl(value: unknown): string {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const url = new URL(raw, window.location.href);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : '';
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function plainText(content: string): string {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = content;
|
||||
return (container.textContent || '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function Studio(): JSX.Element {
|
||||
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||
const [blocks, setBlocks] = useState(() => parse(draft.content || ''));
|
||||
const blocksRef = useRef(blocks);
|
||||
blocksRef.current = blocks;
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const editorRef = useRef<any>(null);
|
||||
const editorBookmarkRef = useRef<any>(null);
|
||||
const pendingRestoreContentRef = useRef<string | null>(null);
|
||||
const editorReadyRef = useRef(false);
|
||||
const saveActionRef = useRef<() => void>(() => undefined);
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const [formatValue, setFormatValue] = useState('p');
|
||||
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
|
||||
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
|
||||
at: '',
|
||||
}));
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [insertOpen, setInsertOpen] = useState(false);
|
||||
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
|
||||
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const restoreBlockSignatureRef = useRef('');
|
||||
|
||||
const restoreHistory = useCallback((value: { blocks: any[]; title: string; excerpt: string }) => {
|
||||
dirtyRef.current = true;
|
||||
blocksRef.current = value.blocks;
|
||||
restoreBlockSignatureRef.current = serialize(value.blocks);
|
||||
setBlocks(value.blocks);
|
||||
const next = { ...draftRef.current, title: value.title, excerpt: value.excerpt };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}, []);
|
||||
const history = useEditorHistory({
|
||||
initial: { blocks, title: draft.title, excerpt: draft.excerpt },
|
||||
onRestore: restoreHistory,
|
||||
});
|
||||
const historyRef = useRef(history);
|
||||
historyRef.current = history;
|
||||
|
||||
const undo = useCallback(() => historyRef.current.undo(), []);
|
||||
const redo = useCallback(() => historyRef.current.redo(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.shiftKey ? redo() : undo();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [redo, undo]);
|
||||
const editRevisionRef = useRef(0);
|
||||
|
||||
const capabilities = bootstrap.capabilities || {};
|
||||
const limits: Limits = {
|
||||
@@ -174,97 +149,103 @@ function Studio(): JSX.Element {
|
||||
|
||||
const patchDraft = useCallback((patch: Partial<Draft>) => {
|
||||
dirtyRef.current = true;
|
||||
editRevisionRef.current += 1;
|
||||
const next = { ...draftRef.current, ...patch };
|
||||
draftRef.current = next;
|
||||
if ('title' in patch || 'excerpt' in patch) {
|
||||
historyRef.current.record({
|
||||
blocks: blocksRef.current,
|
||||
title: next.title,
|
||||
excerpt: next.excerpt,
|
||||
});
|
||||
}
|
||||
setDraft(next);
|
||||
}, []);
|
||||
|
||||
const onChangeBlocks = useCallback((next: any[]) => {
|
||||
dirtyRef.current = true;
|
||||
blocksRef.current = next;
|
||||
const signature = serialize(next);
|
||||
if (restoreBlockSignatureRef.current !== '' && signature === restoreBlockSignatureRef.current) {
|
||||
restoreBlockSignatureRef.current = '';
|
||||
setBlocks(next);
|
||||
const updateContent = useCallback((content: string) => {
|
||||
if (content === draftRef.current.content) {
|
||||
return;
|
||||
}
|
||||
historyRef.current.record({
|
||||
blocks: next,
|
||||
title: draftRef.current.title,
|
||||
excerpt: draftRef.current.excerpt,
|
||||
});
|
||||
setBlocks(next);
|
||||
dirtyRef.current = true;
|
||||
editRevisionRef.current += 1;
|
||||
const next = { ...draftRef.current, content };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}, []);
|
||||
|
||||
const serialized = useMemo(() => serialize(blocks), [blocks]);
|
||||
const updateHistoryAvailability = useCallback((editor: any) => {
|
||||
const manager = editor?.undoManager;
|
||||
if (!manager) return;
|
||||
setHistoryAvailability({
|
||||
canUndo: typeof manager.hasUndo === 'function' && manager.hasUndo(),
|
||||
canRedo: typeof manager.hasRedo === 'function' && manager.hasRedo(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 中文没有空格分词,wordcount 的 'words' 会把整段算成 1。
|
||||
// 'characters_excluding_spaces' 才是中文场景该用的口径(与前台 TinyMCE 字数一致)。
|
||||
const charCount = useMemo(() => {
|
||||
const text = serialized.replace(/<[^>]*>/g, ' ');
|
||||
return countWords(text, 'characters_excluding_spaces', {});
|
||||
}, [serialized]);
|
||||
const updateFormatValue = useCallback((editor: any) => {
|
||||
const node = editor?.selection?.getNode();
|
||||
const block = node ? editor.dom.getParent(node, 'p,h1,h2,h3,blockquote') || node : null;
|
||||
const name = block?.nodeName?.toLowerCase() || 'p';
|
||||
setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : 'p');
|
||||
}, []);
|
||||
|
||||
const buildPayload = useCallback(
|
||||
(extra: Record<string, any> = {}) => ({
|
||||
post_id: draft.postId,
|
||||
version_id: draft.versionId,
|
||||
rev: draft.rev,
|
||||
title: draft.title,
|
||||
excerpt: draft.excerpt,
|
||||
content: serialized,
|
||||
categories: draft.categories,
|
||||
tags: draft.tags,
|
||||
custom: draft.custom,
|
||||
featured: draft.featured,
|
||||
pay: normalizePayForRequest(draft.pay),
|
||||
const charCount = useMemo(() => Array.from(plainText(draft.content)).length, [draft.content]);
|
||||
|
||||
const buildPayload = useCallback((extra: Record<string, any> = {}) => {
|
||||
const current = draftRef.current;
|
||||
return {
|
||||
post_id: current.postId,
|
||||
version_id: current.versionId,
|
||||
rev: current.rev,
|
||||
title: current.title,
|
||||
excerpt: current.excerpt,
|
||||
content: current.content,
|
||||
categories: current.categories,
|
||||
tags: current.tags,
|
||||
custom: current.custom,
|
||||
featured: current.featured,
|
||||
pay: normalizePayForRequest(current.pay),
|
||||
...extra,
|
||||
}),
|
||||
[draft, serialized]
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = useCallback(
|
||||
async (autosave = false): Promise<boolean> => {
|
||||
if (!dirtyRef.current && (draft.postId > 0 || draft.versionId > 0)) {
|
||||
const editor = editorRef.current;
|
||||
if (editor && !editor.removed) {
|
||||
updateContent(editor.getContent());
|
||||
}
|
||||
const current = draftRef.current;
|
||||
if (!dirtyRef.current && (current.postId > 0 || current.versionId > 0)) {
|
||||
return true;
|
||||
}
|
||||
// 空文档不触发自动保存:服务端会拒绝无标题/无内容的写入,
|
||||
// 保留本地快照即可,避免把“保存失败”当成真的故障。
|
||||
const hasDocument = draft.title.trim() !== '' || serialized.replace(/<[^>]*>/g, '').trim() !== '';
|
||||
const requestRevision = editRevisionRef.current;
|
||||
const hasDocument = current.title.trim() !== '' || plainText(current.content).trim() !== '';
|
||||
if (autosave && !hasDocument) {
|
||||
return true;
|
||||
}
|
||||
setSaveState({ state: 'saving', at: '' });
|
||||
try {
|
||||
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
|
||||
const savedId = Number(response.post_id || draft.postId || 0);
|
||||
const versionId = Number(response.version_id || draft.versionId || 0);
|
||||
const rev = Number(response.rev || response.versionRev || draft.rev || 0);
|
||||
dirtyRef.current = false;
|
||||
const updated = { ...draftRef.current, postId: savedId, versionId, rev };
|
||||
const updated = {
|
||||
...draftRef.current,
|
||||
postId: Number(response.post_id || draftRef.current.postId || 0),
|
||||
versionId: Number(response.version_id || draftRef.current.versionId || 0),
|
||||
rev: Number(response.rev || response.versionRev || draftRef.current.rev || 0),
|
||||
};
|
||||
if ((window as any).jioSubmitData) {
|
||||
(window as any).jioSubmitData.postId = updated.postId;
|
||||
}
|
||||
const settled = editRevisionRef.current === requestRevision;
|
||||
dirtyRef.current = !settled;
|
||||
draftRef.current = updated;
|
||||
setDraft(updated);
|
||||
setSaveState({ state: 'saved', at: new Date().toLocaleTimeString() });
|
||||
return true;
|
||||
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleTimeString() : '' });
|
||||
return settled;
|
||||
} catch (error: any) {
|
||||
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[buildPayload, draft.postId, draft.versionId, draft.rev]
|
||||
[buildPayload]
|
||||
);
|
||||
saveActionRef.current = () => void save(false);
|
||||
|
||||
const submit = useCallback(
|
||||
async (publishNow: boolean): Promise<void> => {
|
||||
// 提交前先落一次草稿:审核 / 立即发布都以服务端最终保存的内容为准,
|
||||
// 避免自动保存尚未触发时提交到旧内容。
|
||||
const persisted = await save(false);
|
||||
if (!persisted) {
|
||||
setNotice({ tone: 'error', text: __('内容保存失败,请重试后再提交', 'jio') });
|
||||
@@ -272,8 +253,6 @@ function Studio(): JSX.Element {
|
||||
}
|
||||
setSaveState({ state: 'submitting', at: '' });
|
||||
try {
|
||||
// 站点启用「前台投稿」人机验证时,先完成挑战再提交;
|
||||
// 服务端仍会独立校验证明,前端只是把凭证带过去。
|
||||
let captchaProof = '';
|
||||
const captcha = (window as any).JioCaptcha;
|
||||
if (bootstrap.captcha?.enabled && captcha && typeof captcha.run === 'function') {
|
||||
@@ -331,11 +310,11 @@ function Studio(): JSX.Element {
|
||||
} catch (error: any) {
|
||||
setNotice({ tone: 'error', text: String(error?.message || __('预览失败', 'jio')) });
|
||||
}
|
||||
}, [draft.postId, draft.versionId, save]);
|
||||
}, [save]);
|
||||
|
||||
const { markLocalSaved, clear } = useLocalRecovery({
|
||||
key: STORAGE_KEY,
|
||||
snapshot: { title: draft.title, content: serialized, at: new Date().toISOString() },
|
||||
snapshot: { title: draft.title, content: draft.content, at: new Date().toISOString() },
|
||||
onFound: (savedAt: string) => setRecovery({ savedAt }),
|
||||
});
|
||||
|
||||
@@ -357,7 +336,7 @@ function Studio(): JSX.Element {
|
||||
if (dirtyRef.current) {
|
||||
autosaveSchedule();
|
||||
}
|
||||
}, [draft, serialized, autosaveSchedule]);
|
||||
}, [draft, autosaveSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
@@ -382,56 +361,198 @@ function Studio(): JSX.Element {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [save]);
|
||||
|
||||
useEffect(() => {
|
||||
const wpEditor = (window as any).wp?.editor;
|
||||
if (!wpEditor || typeof wpEditor.initialize !== 'function') {
|
||||
setNotice({ tone: 'error', text: __('TinyMCE 加载失败,请刷新页面后重试。', 'jio') });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const editorSettings: Record<string, any> = {
|
||||
quicktags: false,
|
||||
tinymce: {
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
resize: false,
|
||||
toolbar1: '',
|
||||
toolbar2: '',
|
||||
toolbar3: '',
|
||||
plugins: 'paste,link,lists,wordpress,wplink',
|
||||
wpautop: true,
|
||||
body_class: 'jio-creator__tinymce',
|
||||
content_css: bootstrap.creator?.contentCss || '',
|
||||
content_style: 'body{margin:0;color:#243330;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;font-size:16px;line-height:1.85;letter-spacing:.01em}p{margin:0 0 1.1em}h1,h2,h3,h4,h5,h6{margin:1.5em 0 .65em;line-height:1.4}img,iframe{max-width:100%}img{height:auto}',
|
||||
height: 650,
|
||||
placeholder: String(bootstrap.limits?.contentPlaceholder || ''),
|
||||
setup: (editor: any) => {
|
||||
editorRef.current = editor;
|
||||
editor.on('init', () => {
|
||||
editorReadyRef.current = true;
|
||||
setEditorReady(true);
|
||||
if (pendingRestoreContentRef.current !== null) {
|
||||
editor.setContent(pendingRestoreContentRef.current);
|
||||
pendingRestoreContentRef.current = null;
|
||||
}
|
||||
updateHistoryAvailability(editor);
|
||||
updateFormatValue(editor);
|
||||
});
|
||||
editor.on('NodeChange', () => updateFormatValue(editor));
|
||||
editor.on('input change Undo Redo AddUndo', () => {
|
||||
updateContent(editor.getContent());
|
||||
updateHistoryAvailability(editor);
|
||||
});
|
||||
editor.on('keydown', (event: any) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.keyCode === 83) {
|
||||
event.preventDefault();
|
||||
saveActionRef.current();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
wpEditor.initialize(EDITOR_ID, editorSettings);
|
||||
|
||||
return () => {
|
||||
wpEditor.remove?.(EDITOR_ID);
|
||||
editorReadyRef.current = false;
|
||||
editorRef.current = null;
|
||||
};
|
||||
}, [updateContent]);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed.title === 'string') {
|
||||
patchDraft({ title: parsed.title });
|
||||
}
|
||||
const patch: Partial<Draft> = {};
|
||||
if (typeof parsed.title === 'string') patch.title = parsed.title;
|
||||
if (typeof parsed.content === 'string') patch.content = parsed.content;
|
||||
patchDraft(patch);
|
||||
if (typeof parsed.content === 'string') {
|
||||
onChangeBlocks(parse(parsed.content));
|
||||
if (editorReadyRef.current) {
|
||||
editorRef.current?.setContent(parsed.content);
|
||||
} else {
|
||||
pendingRestoreContentRef.current = parsed.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 快照损坏时忽略,不影响服务端内容。
|
||||
}
|
||||
setRecovery(null);
|
||||
}, [patchDraft]);
|
||||
|
||||
const rememberSelection = useCallback(() => {
|
||||
const editor = editorRef.current;
|
||||
if (editor && !editor.removed) {
|
||||
editorBookmarkRef.current = editor.selection.getBookmark(2, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectedBlockCount = useSelect(
|
||||
(select: any) => select('core/block-editor').getBlockCount(),
|
||||
[]
|
||||
);
|
||||
|
||||
const insertGroups = useMemo(() => buildInsertGroups(capabilities), [capabilities]);
|
||||
|
||||
const insertBlock = useCallback(
|
||||
(item: InsertItem) => {
|
||||
setInsertOpen(false);
|
||||
dirtyRef.current = true;
|
||||
const current = [...blocksRef.current];
|
||||
let next: any[];
|
||||
// 空文档时直接替换初始的空白段落,避免留下一个空行。
|
||||
if (current.length === 1 && current[0].name === 'core/paragraph' && !current[0].attributes?.content) {
|
||||
next = [createBlock(item.name, item.attributes || {})];
|
||||
} else {
|
||||
next = [...current, createBlock(item.name, item.attributes || {})];
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || editor.removed) {
|
||||
return null;
|
||||
}
|
||||
editor.focus();
|
||||
if (editorBookmarkRef.current) {
|
||||
try {
|
||||
editor.selection.moveToBookmark(editorBookmarkRef.current);
|
||||
} catch (error) {
|
||||
// TinyMCE discards invalid bookmarks after content replacement.
|
||||
}
|
||||
blocksRef.current = next;
|
||||
historyRef.current.record({
|
||||
blocks: next,
|
||||
title: draftRef.current.title,
|
||||
excerpt: draftRef.current.excerpt,
|
||||
});
|
||||
setBlocks(next);
|
||||
},
|
||||
[]
|
||||
);
|
||||
editorBookmarkRef.current = null;
|
||||
}
|
||||
return editor;
|
||||
}, []);
|
||||
|
||||
const command = useCallback((name: string, value?: string) => {
|
||||
const editor = focusEditor();
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
if (name === 'undo') {
|
||||
editor.undoManager.undo();
|
||||
} else if (name === 'redo') {
|
||||
editor.undoManager.redo();
|
||||
} else if (name === 'format') {
|
||||
if (value === 'p') {
|
||||
editor.execCommand('FormatBlock', false, 'p');
|
||||
} else {
|
||||
editor.formatter.toggle(value || 'h2');
|
||||
}
|
||||
updateFormatValue(editor);
|
||||
} else {
|
||||
editor.execCommand(name);
|
||||
}
|
||||
editor.nodeChanged();
|
||||
}, [focusEditor]);
|
||||
|
||||
const openLink = useCallback(() => {
|
||||
const editor = focusEditor();
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const wpLink = (window as any).wpLink;
|
||||
if (wpLink && typeof wpLink.open === 'function') {
|
||||
wpLink.open(editor.id);
|
||||
return;
|
||||
}
|
||||
const url = window.prompt(__('输入链接地址', 'jio'), 'https://');
|
||||
const safeUrl = url ? safeHttpUrl(url) : '';
|
||||
if (safeUrl) {
|
||||
editor.execCommand('mceInsertLink', false, { href: safeUrl });
|
||||
}
|
||||
}, [focusEditor]);
|
||||
|
||||
const openMedia = useCallback((type: 'image' | 'video') => {
|
||||
const editor = editorRef.current;
|
||||
const media = (window as any).JioSubmitMedia;
|
||||
if (!editor || !media || typeof media.open !== 'function') {
|
||||
setNotice({ tone: 'error', text: __('媒体工具尚未加载,请刷新页面后重试。', 'jio') });
|
||||
return;
|
||||
}
|
||||
rememberSelection();
|
||||
media.open({
|
||||
type,
|
||||
multiple: type === 'image' ? undefined : false,
|
||||
onSelect: (selected: any) => {
|
||||
const items = Array.isArray(selected) ? selected : [selected];
|
||||
const target = focusEditor();
|
||||
if (!target) return;
|
||||
const markup = items.map((item) => {
|
||||
const url = safeHttpUrl(item?.large_url || item?.url);
|
||||
if (!url) return '';
|
||||
const id = Number(item.attachment_id || item.id || 0);
|
||||
const idAttribute = id > 0 ? ` data-edit-file-id="${id}"` : '';
|
||||
if (type === 'image') {
|
||||
const fullUrl = safeHttpUrl(item?.url || url);
|
||||
const fullAttribute = fullUrl ? ` data-full-url="${escapeAttribute(fullUrl)}"` : '';
|
||||
return `<p><img src="${escapeAttribute(url)}"${idAttribute} alt="${escapeAttribute(String(item.name || item.title || ''))}"${fullAttribute}></p>`;
|
||||
}
|
||||
return `<div contenteditable="false" data-video-url="${escapeAttribute(url)}" data-video-name="${escapeAttribute(String(item.filename || item.name || url))}"${idAttribute} class="new-dplayer post-dplayer dplayer"></div><p></p>`;
|
||||
}).join('');
|
||||
if (markup) {
|
||||
target.insertContent(markup);
|
||||
target.nodeChanged();
|
||||
updateContent(target.getContent());
|
||||
updateHistoryAvailability(target);
|
||||
}
|
||||
},
|
||||
onEmbed: (html: string) => {
|
||||
const target = focusEditor();
|
||||
if (target) {
|
||||
target.insertContent(`${html}<p></p>`);
|
||||
target.nodeChanged();
|
||||
updateContent(target.getContent());
|
||||
updateHistoryAvailability(target);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [focusEditor, rememberSelection]);
|
||||
|
||||
return (
|
||||
<div className="jio-creator" data-status={bootstrap.status || ''} data-blocks={selectedBlockCount}>
|
||||
<div className="jio-creator" data-status={bootstrap.status || ''} data-editor-ready={editorReady ? 'true' : 'false'}>
|
||||
<header className="jio-creator__bar">
|
||||
<div className="jio-creator__bar-left">
|
||||
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'} aria-label={__('返回作者中心', 'jio')}>
|
||||
@@ -444,24 +565,22 @@ function Studio(): JSX.Element {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__bar-center">
|
||||
<span className="jio-creator__doc-title">
|
||||
{draft.title.trim() || __('无标题文章', 'jio')}
|
||||
</span>
|
||||
<EditorToolbar
|
||||
ready={editorReady}
|
||||
formatValue={formatValue}
|
||||
onFormatChange={setFormatValue}
|
||||
canUndo={historyAvailability.canUndo}
|
||||
canRedo={historyAvailability.canRedo}
|
||||
capabilities={capabilities}
|
||||
onCommand={command}
|
||||
onRememberSelection={rememberSelection}
|
||||
onOpenLink={openLink}
|
||||
onOpenMedia={openMedia}
|
||||
/>
|
||||
<div className="jio-creator__bar-actions jio-creator__toolbar-extension" data-toolbar-extension aria-label={__('工具扩展区', 'jio')}>
|
||||
<SaveState value={saveState} />
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__bar-actions">
|
||||
<div className="jio-creator__history" role="group" aria-label={__('编辑历史', 'jio')}>
|
||||
<button type="button" className="jio-creator__icon-btn" onClick={() => history.undo()} disabled={!history.canUndo} aria-label={__('撤销', 'jio')} title={__('撤销', 'jio')}>
|
||||
<i className="fa fas fa-undo" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="jio-creator__icon-btn" onClick={() => history.redo()} disabled={!history.canRedo} aria-label={__('重做', 'jio')} title={__('重做', 'jio')}>
|
||||
<i className="fa fas fa-redo" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="jio-creator__ghost jio-creator__save-draft" onClick={() => void save(false)}>
|
||||
{__('保存草稿', 'jio')}
|
||||
<button type="button" className="jio-creator__save-draft" onClick={() => void save(false)} disabled={!editorReady}>
|
||||
<i className="fa fas fa-save" aria-hidden="true" /><span>{__('保存草稿', 'jio')}</span>
|
||||
</button>
|
||||
<button type="button" className="jio-creator__ghost jio-creator__preview" onClick={() => void preview()}>
|
||||
{__('预览', 'jio')}
|
||||
@@ -490,9 +609,7 @@ function Studio(): JSX.Element {
|
||||
)}
|
||||
|
||||
{isPublishedEdit && (
|
||||
<p className="jio-creator__hint">
|
||||
{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}
|
||||
</p>
|
||||
<p className="jio-creator__hint">{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}</p>
|
||||
)}
|
||||
|
||||
<main className="jio-creator__canvas">
|
||||
@@ -526,51 +643,16 @@ function Studio(): JSX.Element {
|
||||
|
||||
<AuthorRow author={bootstrap.author || {}} />
|
||||
|
||||
<div className="jio-creator__editor">
|
||||
<BlockEditorProvider
|
||||
value={blocks}
|
||||
onInput={onChangeBlocks}
|
||||
onChange={onChangeBlocks}
|
||||
settings={{
|
||||
hasFixedToolbar: false,
|
||||
supportsLayout: false,
|
||||
mediaUpload: (window as any).jioCreatorMediaUpload,
|
||||
}}
|
||||
>
|
||||
<BlockTools>
|
||||
<BlockList />
|
||||
</BlockTools>
|
||||
</BlockEditorProvider>
|
||||
|
||||
{/* 公众号式常驻插入按钮:始终停在正文末尾,点击打开内容面板。 */}
|
||||
<button
|
||||
type="button"
|
||||
className="jio-creator__insert"
|
||||
onClick={() => setInsertOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={insertOpen}
|
||||
>
|
||||
<i className="fa fas fa-plus" aria-hidden="true" />
|
||||
<span>{__('添加内容', 'jio')}</span>
|
||||
</button>
|
||||
<div className="jio-creator__editor" data-editor-ready={editorReady ? 'true' : 'false'}>
|
||||
<textarea id={EDITOR_ID} defaultValue={draft.content} onChange={(event) => updateContent(event.target.value)} aria-label={__('文章正文', 'jio')} />
|
||||
</div>
|
||||
|
||||
<footer className="jio-creator__footer">
|
||||
<span>{__('字数', 'jio')}:{charCount}</span>
|
||||
<span>
|
||||
{__('约', 'jio')} {Math.max(1, Math.round(charCount / 400))} {__('分钟阅读', 'jio')}
|
||||
</span>
|
||||
<span>{__('约', 'jio')} {Math.max(1, Math.round(charCount / 400))} {__('分钟阅读', 'jio')}</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
{insertOpen && (
|
||||
<InsertPanel
|
||||
groups={insertGroups}
|
||||
onInsert={insertBlock}
|
||||
onClose={() => setInsertOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{drawerOpen && (
|
||||
<PublishDrawer
|
||||
draft={draft}
|
||||
@@ -601,9 +683,6 @@ function mount() {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
installApiFetchNonce(nonce);
|
||||
installMediaUploadBridge(bootstrap, nonce);
|
||||
installImageWidthPresets();
|
||||
target.classList.add('is-ready');
|
||||
createRoot(target).render(<Studio />);
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* 悬浮插入面板(公众号式)。
|
||||
*
|
||||
* 正文底部常驻一个「+」,点开是分组网格:常用文本、媒体、样式。
|
||||
* 桌面端为居中浮层,移动端贴底弹出;键盘可操作,Esc 关闭。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export type InsertItem = {
|
||||
name: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
attributes?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type InsertGroup = {
|
||||
label: string;
|
||||
items: InsertItem[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
groups: InsertGroup[];
|
||||
onInsert: (item: InsertItem) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function InsertPanel({ groups, onInsert, onClose }: Props) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const items = useMemo(() => groups.flatMap((group) => group.items), [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus();
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
// 在网格内用方向键移动焦点,避免必须用鼠标。
|
||||
const buttons = Array.from(
|
||||
panelRef.current?.querySelectorAll<HTMLButtonElement>('.jio-insert-item') || []
|
||||
);
|
||||
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const columns = window.innerWidth <= 767 ? 4 : 6;
|
||||
const delta =
|
||||
event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowDown' ? columns : -columns;
|
||||
const next = Math.min(buttons.length - 1, Math.max(0, index + delta));
|
||||
buttons[next]?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="jio-insert" role="dialog" aria-modal="true" aria-label={__('添加内容', 'jio')}>
|
||||
<button
|
||||
type="button"
|
||||
className="jio-insert__backdrop"
|
||||
aria-label={__('关闭', 'jio')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="jio-insert__panel" ref={panelRef}>
|
||||
<header className="jio-insert__head">
|
||||
<h2>{__('添加内容', 'jio')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="jio-insert__close"
|
||||
onClick={onClose}
|
||||
ref={closeRef}
|
||||
aria-label={__('关闭', 'jio')}
|
||||
>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="jio-insert__body">
|
||||
{groups.map((group) =>
|
||||
group.items.length === 0 ? null : (
|
||||
<section className="jio-insert__group" key={group.label}>
|
||||
<h3>{group.label}</h3>
|
||||
<div className="jio-insert__grid">
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
className="jio-insert-item"
|
||||
onClick={() => onInsert(item)}
|
||||
>
|
||||
<span className="jio-insert-item__icon" aria-hidden="true">
|
||||
<i className={item.icon} />
|
||||
</span>
|
||||
<span className="jio-insert-item__label">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<p className="jio-creator-muted">{__('当前账号没有可插入的内容类型。', 'jio')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 给 @wordpress/api-fetch 注入 REST nonce。
|
||||
*
|
||||
* WordPress 仅在后台为 wp-api 注入 wpApiSettings(含 nonce),前台工作台里
|
||||
* Gutenberg 的 core-data 请求(如 /wp/v2/users/me)因此全部 403。这里统一挂一个
|
||||
* nonce 中间件,让区块编辑器的数据请求与 Jio REST 使用同一次页面加载的 nonce。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
|
||||
export function installApiFetchNonce(nonce: string): void {
|
||||
if (!nonce || typeof apiFetch.use !== 'function') {
|
||||
return;
|
||||
}
|
||||
if ((window as any).__jioApiFetchNonceInstalled) {
|
||||
return;
|
||||
}
|
||||
(window as any).__jioApiFetchNonceInstalled = true;
|
||||
|
||||
// createNonceMiddleware 在收到 rest_cookie_invalid_nonce 时会带着 nonce 重试,
|
||||
// 比在每次请求上手工加 header 更稳妥。
|
||||
const factory = (apiFetch as any).createNonceMiddleware;
|
||||
if (typeof factory === 'function') {
|
||||
apiFetch.use(factory(nonce));
|
||||
return;
|
||||
}
|
||||
apiFetch.use((options: any, next: any) => {
|
||||
options.headers = { ...(options.headers || {}), 'X-WP-Nonce': nonce };
|
||||
return next(options);
|
||||
});
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* 编辑器媒体上传桥。
|
||||
*
|
||||
* Gutenberg 核心图片区块通过 block-editor settings.mediaUpload 上传;这里把它接到
|
||||
* Jio 前台上传接口,保留 Jio 的能力校验、文件校验与待认领生命周期,而不是让区块
|
||||
* 直接走 /wp/v2/media。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
export function installMediaUploadBridge(bootstrap: Record<string, any>, nonce: string) {
|
||||
const url = bootstrap?.blocks?.mediaUploadUrl;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const maxBytes = Number(bootstrap?.media?.maxBytes || 0);
|
||||
const allowed = bootstrap?.capabilities?.canUploadImage !== false;
|
||||
|
||||
window['jioCreatorMediaUpload'] = (payload: any) => {
|
||||
const { file, onError, onSuccess } = payload || {};
|
||||
if (!file || typeof file === 'object' && !(file instanceof File)) {
|
||||
onError?.('无效的文件');
|
||||
return;
|
||||
}
|
||||
if (!allowed) {
|
||||
onError?.('当前账号不能上传图片');
|
||||
return;
|
||||
}
|
||||
if (maxBytes > 0 && file.size > maxBytes) {
|
||||
onError?.('图片大小超过限制');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': nonce },
|
||||
body,
|
||||
})
|
||||
.then((response) =>
|
||||
response.json().then((data) => {
|
||||
if (!response.ok) {
|
||||
throw new Error((data && data.message) || '上传失败');
|
||||
}
|
||||
return data;
|
||||
})
|
||||
)
|
||||
.then((data) => {
|
||||
onSuccess?.({
|
||||
id: Number(data.attachment_id || data.id || 0),
|
||||
url: String(data.url || ''),
|
||||
alt: '',
|
||||
caption: '',
|
||||
title: String(data.title || data.name || ''),
|
||||
width: Number(data.width || 0),
|
||||
height: Number(data.height || 0),
|
||||
sizes: data.sizes || {},
|
||||
mime: String(data.mime || ''),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error?.message || '上传失败');
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
@use './tokens' as *;
|
||||
|
||||
|
||||
|
||||
.jio-creator-drawer {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(23, 34, 31, 0.32);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: min(460px, 100%);
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
box-shadow: -20px 0 60px rgba(20, 32, 28, 0.16);
|
||||
}
|
||||
|
||||
.jio-creator-drawer__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid $line;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-drawer__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $ink-soft;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 32px;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__note {
|
||||
margin: 0 0 20px;
|
||||
padding: 12px 14px;
|
||||
border-left: 3px solid $accent;
|
||||
background: #f2f7f4;
|
||||
color: $ink-soft;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 24px calc(16px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid $line;
|
||||
background: #fff;
|
||||
|
||||
.jio-creator-primary {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-drawer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__captcha {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.jio-creator-field {
|
||||
margin-bottom: 26px;
|
||||
|
||||
> h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-muted {
|
||||
margin: 0;
|
||||
color: $ink-soft;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jio-creator-error {
|
||||
margin: 8px 0 0;
|
||||
color: $warn;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.jio-creator-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jio-creator-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 999px;
|
||||
color: $ink-soft;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: $accent;
|
||||
background: #eef5f2;
|
||||
color: $accent-strong;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
input[type="text"],
|
||||
input:not([type]) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input:not([type]) {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-switch {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-toggle {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #d6dfda;
|
||||
transition: background 0.18s ease;
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 3px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
content: "";
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background: $accent;
|
||||
}
|
||||
|
||||
input:checked + span::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
input:focus-visible + span {
|
||||
outline: 2px solid $accent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-pay {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 10px;
|
||||
background: #fcfdfc;
|
||||
}
|
||||
|
||||
.jio-creator-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
input {
|
||||
width: 140px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-cover-empty {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px dashed $line;
|
||||
border-radius: 10px;
|
||||
background: #fafcfb;
|
||||
color: $ink-soft;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-attachments {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $line;
|
||||
}
|
||||
|
||||
.jio-creator-attachments__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
color: $accent-strong;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-files {
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f4f1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $ink-soft;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-files__name {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-reuse {
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
border: 1px solid $line;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--jc-ink-soft, #{$ink-soft});
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
$ink: #243330;
|
||||
$ink-soft: #5c6b66;
|
||||
$paper: #fafbfa;
|
||||
$line: #e2e9e5;
|
||||
$accent: #267665;
|
||||
$accent-strong: #1d5f52;
|
||||
$warn: #b4573f;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 插入面板的可插入条目。
|
||||
*
|
||||
* 只列出当前账号真正能用的类型:图片/视频依赖上传能力,付费片段依赖付费能力。
|
||||
* 区块名用 WordPress 核心与 Jio 自有区块的真实注册名,避免插入时报
|
||||
* "Block type is not registered"。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
import type { InsertGroup } from '../panels/insert-panel';
|
||||
|
||||
type Capabilities = {
|
||||
canUploadImage?: boolean;
|
||||
canUploadVideo?: boolean;
|
||||
canPay?: boolean;
|
||||
canEmbed?: boolean;
|
||||
};
|
||||
|
||||
export function buildInsertGroups(capabilities: Capabilities): InsertGroup[] {
|
||||
const text: InsertGroup = {
|
||||
label: __('文字', 'jio'),
|
||||
items: [
|
||||
{ name: 'core/paragraph', title: __('正文', 'jio'), icon: 'fa fas fa-align-left' },
|
||||
{ name: 'core/heading', title: __('标题', 'jio'), icon: 'fa fas fa-heading', attributes: { level: 2 } },
|
||||
{ name: 'core/list', title: __('列表', 'jio'), icon: 'fa fas fa-list-ul' },
|
||||
{ name: 'core/quote', title: __('引用', 'jio'), icon: 'fa fas fa-quote-left' },
|
||||
],
|
||||
};
|
||||
|
||||
const media: InsertGroup = {
|
||||
label: __('媒体', 'jio'),
|
||||
items: [],
|
||||
};
|
||||
if (capabilities.canUploadImage) {
|
||||
media.items.push({ name: 'core/image', title: __('图片', 'jio'), icon: 'fa far fa-image' });
|
||||
}
|
||||
if (capabilities.canUploadVideo) {
|
||||
media.items.push({ name: 'core/video', title: __('视频', 'jio'), icon: 'fa fas fa-video' });
|
||||
}
|
||||
if (capabilities.canEmbed) {
|
||||
media.items.push({ name: 'core/embed', title: __('嵌入', 'jio'), icon: 'fa fas fa-code' });
|
||||
}
|
||||
|
||||
const blocks: InsertGroup = {
|
||||
label: __('组件', 'jio'),
|
||||
items: [
|
||||
{ name: 'core/code', title: __('代码', 'jio'), icon: 'fa fas fa-terminal' },
|
||||
{ name: 'core/separator', title: __('分隔线', 'jio'), icon: 'fa fas fa-minus' },
|
||||
{ name: 'core/table', title: __('表格', 'jio'), icon: 'fa fas fa-table' },
|
||||
],
|
||||
};
|
||||
|
||||
if (capabilities.canPay) {
|
||||
blocks.items.push({
|
||||
name: 'jio/paid-fragment',
|
||||
title: __('付费内容', 'jio'),
|
||||
icon: 'fa fas fa-lock',
|
||||
});
|
||||
}
|
||||
|
||||
return [text, media, blocks];
|
||||
}
|
||||
Reference in new issue
Block a user