feat(creator-studio): dialog-driven table/math/footnote, theme quote, code language picker
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7c33482be7
commit
7885f42cff
14 files changed
+1005
-54
No files matched your search
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 通用插入弹窗:表格 / 公式 / 注释共用。
|
||||
*
|
||||
* 结构与媒体选择弹窗保持一致(遮罩 + 对话框 + 标题 + 主体 + 底部按钮),
|
||||
* 让作者在三类块上都是「先填参数、再插入」的同一套交互。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
import { CODE_LANGUAGES } from '../utils/block-markup';
|
||||
|
||||
export type BlockDialogKind = 'table' | 'math' | 'footnote' | 'code';
|
||||
|
||||
export type BlockDialogResult =
|
||||
| { kind: 'table'; rows: number; cols: number; header: boolean; striped: boolean }
|
||||
| { kind: 'math'; formula: string; display: boolean }
|
||||
| { kind: 'footnote'; note: string }
|
||||
| { kind: 'code'; language: string; code: string };
|
||||
|
||||
type Props = {
|
||||
kind: BlockDialogKind;
|
||||
initialLanguage?: string;
|
||||
initialCode?: string;
|
||||
onSubmit: (result: BlockDialogResult) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, Number.isFinite(value) ? Math.round(value) : min));
|
||||
|
||||
const TITLES: Record<BlockDialogKind, string> = {
|
||||
table: __('插入表格', 'jio'),
|
||||
math: __('插入公式', 'jio'),
|
||||
footnote: __('插入注释', 'jio'),
|
||||
code: __('插入代码块', 'jio'),
|
||||
};
|
||||
|
||||
export function BlockDialog({ kind, initialLanguage = 'generic', initialCode = '', onSubmit, onClose }: Props) {
|
||||
const [rows, setRows] = useState(3);
|
||||
const [cols, setCols] = useState(3);
|
||||
const [header, setHeader] = useState(true);
|
||||
const [striped, setStriped] = useState(false);
|
||||
const [formula, setFormula] = useState('a^2 + b^2 = c^2');
|
||||
const [display, setDisplay] = useState(true);
|
||||
const [note, setNote] = useState('');
|
||||
const [language, setLanguage] = useState(initialLanguage);
|
||||
const [code, setCode] = useState(initialCode);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
const formulaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const noteRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(firstFieldRef.current || formulaRef.current || noteRef.current)?.focus();
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (kind === 'footnote') {
|
||||
return note.trim() !== '';
|
||||
}
|
||||
if (kind === 'math') {
|
||||
return formula.trim() !== '';
|
||||
}
|
||||
return true;
|
||||
}, [kind, note, formula]);
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
if (kind === 'table') {
|
||||
onSubmit({ kind, rows: clamp(rows, 1, 20), cols: clamp(cols, 1, 10), header, striped });
|
||||
return;
|
||||
}
|
||||
if (kind === 'math') {
|
||||
onSubmit({ kind, formula: formula.trim(), display });
|
||||
return;
|
||||
}
|
||||
if (kind === 'code') {
|
||||
onSubmit({ kind, language, code });
|
||||
return;
|
||||
}
|
||||
onSubmit({ kind, note: note.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jio-creator-dialog" role="dialog" aria-modal="true" aria-labelledby="jio-creator-dialog-title">
|
||||
<button type="button" className="jio-creator-dialog__backdrop" aria-label={__('关闭', 'jio')} onClick={onClose} />
|
||||
<div className="jio-creator-dialog__panel" ref={panelRef}>
|
||||
<header className="jio-creator-dialog__head">
|
||||
<h2 id="jio-creator-dialog-title">{TITLES[kind]}</h2>
|
||||
<button type="button" className="jio-creator-dialog__close" onClick={onClose} aria-label={__('关闭', 'jio')}>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="jio-creator-dialog__body">
|
||||
{kind === 'table' && (
|
||||
<>
|
||||
<div className="jio-creator-dialog__row">
|
||||
<label>
|
||||
<span>{__('行数', 'jio')}</span>
|
||||
<input
|
||||
ref={firstFieldRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={rows}
|
||||
onChange={(event) => setRows(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{__('列数', 'jio')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={cols}
|
||||
onChange={(event) => setCols(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="jio-creator-dialog__check">
|
||||
<input type="checkbox" checked={header} onChange={(event) => setHeader(event.target.checked)} />
|
||||
<span>{__('首行为表头', 'jio')}</span>
|
||||
</label>
|
||||
<label className="jio-creator-dialog__check">
|
||||
<input type="checkbox" checked={striped} onChange={(event) => setStriped(event.target.checked)} />
|
||||
<span>{__('隔行变色', 'jio')}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === 'math' && (
|
||||
<>
|
||||
<label className="jio-creator-dialog__field">
|
||||
<span>{__('公式', 'jio')}</span>
|
||||
<textarea
|
||||
ref={formulaRef}
|
||||
rows={3}
|
||||
value={formula}
|
||||
onChange={(event) => setFormula(event.target.value)}
|
||||
placeholder="a^2 + b^2 = c^2"
|
||||
/>
|
||||
</label>
|
||||
<p className="jio-creator-muted">
|
||||
{__('支持上标 ^ 与下标 _,例如 x^2 与 a_n;暂不支持完整 LaTeX。', 'jio')}
|
||||
</p>
|
||||
<label className="jio-creator-dialog__check">
|
||||
<input type="checkbox" checked={display} onChange={(event) => setDisplay(event.target.checked)} />
|
||||
<span>{__('独占一行显示', 'jio')}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === 'code' && (
|
||||
<>
|
||||
<label className="jio-creator-dialog__field">
|
||||
<span>{__('语言', 'jio')}</span>
|
||||
<select value={language} onChange={(event) => setLanguage(event.target.value)}>
|
||||
{CODE_LANGUAGES.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="jio-creator-dialog__field">
|
||||
<span>{__('代码(可留空,稍后在正文里填写)', 'jio')}</span>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="console.log('hello');"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === 'footnote' && (
|
||||
<label className="jio-creator-dialog__field">
|
||||
<span>{__('注释内容', 'jio')}</span>
|
||||
<textarea
|
||||
ref={noteRef}
|
||||
rows={4}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder={__('填写要补充说明的内容', 'jio')}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="jio-creator-dialog__foot">
|
||||
<button type="button" className="jio-creator-dialog__ghost" onClick={onClose}>
|
||||
{__('取消', 'jio')}
|
||||
</button>
|
||||
<button type="button" className="jio-creator-dialog__primary" onClick={submit} disabled={!canSubmit}>
|
||||
{__('插入', 'jio')}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
type Capabilities = Record<string, boolean>;
|
||||
@@ -23,6 +24,7 @@ type Props = {
|
||||
onOpenLink: () => void;
|
||||
onOpenMedia: (type: 'image' | 'video' | 'file') => void;
|
||||
onInsertBlock: (kind: string) => void;
|
||||
onInsertQuote: (color: '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv') => void;
|
||||
};
|
||||
|
||||
type ToolButtonProps = {
|
||||
@@ -61,7 +63,34 @@ export function EditorToolbar({
|
||||
onOpenLink,
|
||||
onOpenMedia,
|
||||
onInsertBlock,
|
||||
onInsertQuote,
|
||||
}: Props) {
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const quoteRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 点击别处或按 Esc 收起引用样式菜单。
|
||||
useEffect(() => {
|
||||
if (!quoteOpen) {
|
||||
return undefined;
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!quoteRef.current?.contains(event.target as Node)) {
|
||||
setQuoteOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setQuoteOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [quoteOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="jio-creator__toolbar-tools"
|
||||
@@ -112,7 +141,33 @@ export function EditorToolbar({
|
||||
<div className="jio-creator__toolbar-group" role="group" aria-label={__('段落块', 'jio')}>
|
||||
<ToolButton label={__('列表', 'jio')} icon="fa fas fa-list-ul" ready={ready} onClick={() => onInsertBlock('list')} />
|
||||
<ToolButton label={__('目录', 'jio')} icon="fa fas fa-list-ol" ready={ready} onClick={() => onInsertBlock('toc')} />
|
||||
<ToolButton label={__('引用', 'jio')} icon="fa fas fa-quote-left" ready={ready} onClick={() => onCommand('format', 'blockquote')} />
|
||||
<div className="jio-creator__quote" ref={quoteRef}>
|
||||
<ToolButton label={__('引用', 'jio')} icon="fa fas fa-quote-left" ready={ready} onClick={() => setQuoteOpen((open) => !open)} />
|
||||
{quoteOpen && (
|
||||
<div className="jio-creator__quote-menu" role="menu" aria-label={__('引用样式', 'jio')}>
|
||||
{[
|
||||
{ key: '' as const, label: __('灰色引用', 'jio'), className: '' },
|
||||
{ key: 'qe_wzk_c-red' as const, label: __('红色引用', 'jio'), className: 'is-red' },
|
||||
{ key: 'qe_wzk_lan' as const, label: __('蓝色引用', 'jio'), className: 'is-blue' },
|
||||
{ key: 'qe_wzk_lv' as const, label: __('绿色引用', 'jio'), className: 'is-green' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.key || 'default'}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`jio-creator__quote-option ${option.className}`.trim()}
|
||||
onClick={() => {
|
||||
setQuoteOpen(false);
|
||||
onInsertQuote(option.key);
|
||||
}}
|
||||
>
|
||||
<span className="jio-creator__quote-swatch" aria-hidden="true" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ToolButton label={__('分割线', 'jio')} icon="fa fas fa-minus" ready={ready} onClick={() => onInsertBlock('divider')} />
|
||||
<ToolButton label={__('代码块', 'jio')} icon="fa fas fa-code" ready={ready} onClick={() => onInsertBlock('code')} />
|
||||
<ToolButton label={__('注释', 'jio')} icon="fa fas fa-comment-dots" ready={ready} onClick={() => onInsertBlock('footnote')} />
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* TinyMCE iframe 内的正文样式。
|
||||
*
|
||||
* 编辑器正文跑在 iframe 里,页面级 SCSS 影响不到它,所以这里显式给出:
|
||||
* 正文排版、主题引用 .quote_q、表格、公式、目录占位,以及代码块右上角的
|
||||
* 语言标签(可点击,由父组件监听 pre 的点击打开语言选择)。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
export const EDITOR_CONTENT_STYLE = `
|
||||
body {
|
||||
margin: 0;
|
||||
color: #2b2b2b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
p { margin: 0 0 1.1em; }
|
||||
h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.65em; line-height: 1.4; }
|
||||
img, iframe { max-width: 100%; }
|
||||
img { height: auto; }
|
||||
|
||||
/* 代码块:右上角显示当前语言,点击即可更换(交互在父组件里接管)。 */
|
||||
.enlighter-pre {
|
||||
position: relative;
|
||||
margin: 0 0 1.1em;
|
||||
padding: 40px 14px 14px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
background: #f2f4f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.enlighter-pre code.gl {
|
||||
display: block;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #2b2b2b;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.enlighter-pre::before {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #3dcabd 10%, #254bdd 100%);
|
||||
color: #fff;
|
||||
content: attr(data-enlighter-language);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 引用:与前台主题的 .quote_q 视觉一致。 */
|
||||
.quote_q {
|
||||
position: relative;
|
||||
margin: 10px 0 20px;
|
||||
padding: 1.2em;
|
||||
border-radius: 6px;
|
||||
color: var(--quote-color, #555);
|
||||
background: rgba(85, 85, 85, 0.04);
|
||||
}
|
||||
.quote_q.qe_wzk_c-red { --quote-color: #dc3f3f; background: rgba(220, 63, 63, 0.05); }
|
||||
.quote_q.qe_wzk_lan { --quote-color: #0092ff; background: rgba(0, 146, 255, 0.05); }
|
||||
.quote_q.qe_wzk_lv { --quote-color: #23790e; background: rgba(35, 121, 14, 0.05); }
|
||||
.quote_q::after {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
color: var(--quote-color, #555);
|
||||
content: '\\201C';
|
||||
font-size: 1.4em;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.quote_q > div { padding-left: 2em; opacity: 0.85; }
|
||||
.quote_q > div > p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* 表格。 */
|
||||
.jio-content-table {
|
||||
width: 100%;
|
||||
margin: 0 0 1.1em;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.jio-content-table th,
|
||||
.jio-content-table td {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e6e9e8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.jio-content-table thead th {
|
||||
background: #f6f8f7;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.jio-content-table.is-striped tbody tr:nth-child(odd) td { background: #f7f9f8; }
|
||||
|
||||
/* 公式与目录占位。 */
|
||||
.jio-math { font-family: "Cambria Math", "Latin Modern Math", "Times New Roman", serif; }
|
||||
.jio-math--block { display: block; margin: 12px 0; text-align: center; }
|
||||
.jio-toc-placeholder {
|
||||
height: 40px;
|
||||
margin: 0 0 1.1em;
|
||||
border: 1px dashed #d3ded8;
|
||||
border-radius: 8px;
|
||||
background: #f7faf9;
|
||||
color: #8c9a94;
|
||||
font-size: 12px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.jio-toc-placeholder::after { content: '目录将在发布后自动生成'; }
|
||||
|
||||
/* 附件链接。 */
|
||||
.jio-file-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e6e9e8;
|
||||
border-radius: 6px;
|
||||
background: #f7f8f8;
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
@@ -18,7 +18,19 @@ import { SaveState, 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 { codeBlockMarkup, dividerMarkup, footnoteMarkup, listMarkup, mathMarkup, tableMarkup, tocMarkup } from './utils/block-markup';
|
||||
import { EDITOR_CONTENT_STYLE } from './editor-content-style';
|
||||
import {
|
||||
codeBlockMarkup,
|
||||
dividerMarkup,
|
||||
footnoteMarkup,
|
||||
listMarkup,
|
||||
mathMarkup,
|
||||
quoteMarkup,
|
||||
tableMarkup,
|
||||
tocMarkup,
|
||||
type QuoteColor,
|
||||
} from './utils/block-markup';
|
||||
import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog';
|
||||
import './styles/studio.scss';
|
||||
|
||||
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
|
||||
@@ -132,6 +144,9 @@ function Studio(): JSX.Element {
|
||||
at: '',
|
||||
}));
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [blockDialog, setBlockDialog] = useState<BlockDialogKind | null>(null);
|
||||
const [codeDialog, setCodeDialog] = useState<null | { language: string; code: string }>(null);
|
||||
const codeTargetRef = useRef<any>(null);
|
||||
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
|
||||
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
@@ -384,7 +399,7 @@ function Studio(): JSX.Element {
|
||||
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}',
|
||||
content_style: EDITOR_CONTENT_STYLE,
|
||||
height: 650,
|
||||
placeholder: String(bootstrap.limits?.contentPlaceholder || ''),
|
||||
setup: (editor: any) => {
|
||||
@@ -400,6 +415,24 @@ function Studio(): JSX.Element {
|
||||
updateFormatValue(editor);
|
||||
});
|
||||
editor.on('NodeChange', () => updateFormatValue(editor));
|
||||
// 代码块右上角的语言标签:点击整块即可换语言,等价于公众号的「高亮」入口。
|
||||
editor.on('click', (event: any) => {
|
||||
const pre = editor.dom.getParent(event.target, 'pre.enlighter-pre');
|
||||
if (!pre) {
|
||||
return;
|
||||
}
|
||||
// 只响应右上角语言标签那一片,光标点进代码正文时保持正常编辑。
|
||||
const rect = pre.getBoundingClientRect();
|
||||
const relativeY = Number(event.clientY) - rect.top;
|
||||
const relativeX = Number(event.clientX) - rect.left;
|
||||
if (relativeY > 40 || relativeX < rect.width - 120) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const language = String(pre.getAttribute('data-enlighter-language') || 'generic');
|
||||
codeTargetRef.current = pre;
|
||||
setCodeDialog({ language, code: String((pre.textContent || '').trim()) });
|
||||
});
|
||||
editor.on('input change Undo Redo AddUndo', () => {
|
||||
updateContent(editor.getContent());
|
||||
updateHistoryAvailability(editor);
|
||||
@@ -508,22 +541,9 @@ function Studio(): JSX.Element {
|
||||
}
|
||||
}, [focusEditor]);
|
||||
|
||||
const insertBlock = useCallback((kind: string) => {
|
||||
const insertHtml = useCallback((html: string) => {
|
||||
const editor = focusEditor();
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const markup: Record<string, string> = {
|
||||
list: listMarkup(false),
|
||||
toc: tocMarkup(),
|
||||
divider: dividerMarkup(),
|
||||
code: codeBlockMarkup(),
|
||||
footnote: footnoteMarkup(),
|
||||
math: mathMarkup(),
|
||||
table: tableMarkup(),
|
||||
};
|
||||
const html = markup[kind];
|
||||
if (!html) {
|
||||
if (!editor || !html) {
|
||||
return;
|
||||
}
|
||||
editor.insertContent(html);
|
||||
@@ -532,6 +552,60 @@ function Studio(): JSX.Element {
|
||||
updateHistoryAvailability(editor);
|
||||
}, [focusEditor]);
|
||||
|
||||
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
|
||||
const insertBlock = useCallback((kind: string) => {
|
||||
rememberSelection();
|
||||
// code 走「语言选择」弹窗,其余三类走通用插入弹窗。
|
||||
if (kind === 'table' || kind === 'math' || kind === 'footnote') {
|
||||
setBlockDialog(kind as BlockDialogKind);
|
||||
return;
|
||||
}
|
||||
if (kind === 'code') {
|
||||
setCodeDialog({ language: 'generic', code: '' });
|
||||
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 if (result.kind === 'footnote') {
|
||||
insertHtml(footnoteMarkup(result.note));
|
||||
} else {
|
||||
insertHtml(codeBlockMarkup(result.language, result.code));
|
||||
}
|
||||
}, [insertHtml]);
|
||||
|
||||
const openMedia = useCallback((type: 'image' | 'video' | 'file') => {
|
||||
const editor = editorRef.current;
|
||||
const media = (window as any).JioSubmitMedia;
|
||||
@@ -608,6 +682,7 @@ function Studio(): JSX.Element {
|
||||
onOpenLink={openLink}
|
||||
onOpenMedia={openMedia}
|
||||
onInsertBlock={insertBlock}
|
||||
onInsertQuote={insertQuote}
|
||||
/>
|
||||
<div className="jio-creator__bar-actions jio-creator__toolbar-extension" data-toolbar-extension aria-label={__('工具扩展区', 'jio')}>
|
||||
<SaveState value={saveState} />
|
||||
@@ -676,6 +751,43 @@ function Studio(): JSX.Element {
|
||||
onScheduleChange={(enabled, at) => patchDraft({ schedule: { enabled, at } })}
|
||||
/>
|
||||
|
||||
{blockDialog && (
|
||||
<BlockDialog
|
||||
kind={blockDialog}
|
||||
onSubmit={applyBlockDialog}
|
||||
onClose={() => setBlockDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{codeDialog && (
|
||||
<BlockDialog
|
||||
kind="code"
|
||||
initialLanguage={codeDialog.language}
|
||||
initialCode={codeDialog.code}
|
||||
onSubmit={(result) => {
|
||||
setCodeDialog(null);
|
||||
if (result.kind !== 'code') {
|
||||
return;
|
||||
}
|
||||
const previous = codeTargetRef.current;
|
||||
codeTargetRef.current = null;
|
||||
insertHtml(codeBlockMarkup(result.language, result.code));
|
||||
if (previous) {
|
||||
// 换语言时移除旧块,避免留下两份代码。
|
||||
const editor = editorRef.current;
|
||||
if (editor && !editor.removed) {
|
||||
editor.dom.remove(previous);
|
||||
updateContent(editor.getContent());
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setCodeDialog(null);
|
||||
codeTargetRef.current = null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{drawerOpen && (
|
||||
<PublishDrawer
|
||||
draft={draft}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 通用插入弹窗 + 引用样式菜单 + 正文块(表格 / 代码 / 公式)的编辑器内样式。
|
||||
*/
|
||||
|
||||
/* ---------- 通用插入弹窗 ---------- */
|
||||
|
||||
.jio-creator-dialog {
|
||||
position: fixed;
|
||||
z-index: 560;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(23, 34, 31, 0.34);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: min(460px, 100%);
|
||||
max-height: min(82vh, 640px);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 70px rgba(20, 32, 28, 0.22);
|
||||
}
|
||||
|
||||
.jio-creator-dialog__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid #eef1f0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-dialog__close {
|
||||
display: inline-flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #7d8683;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #f2f5f3;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-dialog__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-dialog__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__row label > span,
|
||||
.jio-creator-dialog__field > span {
|
||||
color: #6f7875;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__field input,
|
||||
.jio-creator-dialog__field select,
|
||||
.jio-creator-dialog__row input {
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e3e7e5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #2b2b2b;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__field textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #e3e7e5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #2b2b2b;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(38, 118, 101, 0.25);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-dialog__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #4f5957;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--jc-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-dialog__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 20px calc(14px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #eef1f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__ghost,
|
||||
.jio-creator-dialog__primary {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.jio-creator-dialog__ghost {
|
||||
border: 1px solid #e3e7e5;
|
||||
background: #fff;
|
||||
color: #5f6664;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: #b7cdc3;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-dialog__primary {
|
||||
border: 1px solid transparent;
|
||||
background: var(--jc-accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background: var(--jc-accent-strong);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 引用样式菜单 ---------- */
|
||||
|
||||
.jio-creator__quote {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.jio-creator__quote-menu {
|
||||
position: absolute;
|
||||
z-index: 400;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 148px;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 34px rgba(20, 32, 28, 0.18);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.jio-creator__quote-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #4f5957;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #f2f5f3;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__quote-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #6f7875;
|
||||
}
|
||||
|
||||
.is-red .jio-creator__quote-swatch {
|
||||
background: #dc3f3f;
|
||||
}
|
||||
|
||||
.is-blue .jio-creator__quote-swatch {
|
||||
background: #0092ff;
|
||||
}
|
||||
|
||||
.is-green .jio-creator__quote-swatch {
|
||||
background: #23790e;
|
||||
}
|
||||
@@ -508,6 +508,7 @@ body.jio-page-submit.has-static-nav {
|
||||
color: #b9bfbc;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ---------- 发布设置卡片 ---------- */
|
||||
@@ -678,6 +679,7 @@ body.jio-page-submit.has-static-nav {
|
||||
}
|
||||
|
||||
@import './_publish-drawer';
|
||||
@import './_block-tools';
|
||||
|
||||
/* ---------- 手机 / 平板 ---------- */
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* 正文块的 HTML 片段。
|
||||
*
|
||||
* 这些块都由服务端再次清洗(FrontSubmitSave 的 wp_kses_post),因此这里只负责
|
||||
* 生成符合前台渲染习惯的结构:代码块沿用 enlighter 的既有高亮约定,脚注与公式
|
||||
* 使用主题前台已有的渲染口径,目录使用文章目录树识别标题的 data-nav 结构。
|
||||
* 生成符合前台渲染习惯的结构:代码块沿用 enlighter 的既有高亮约定,引用沿用
|
||||
* 主题的 .quote_q 结构,目录使用文章目录树识别标题的占位结构。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
@@ -24,27 +24,71 @@ export function dividerMarkup(): string {
|
||||
return '<hr><p></p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块:沿用前台 enlighter 的高亮标记,未选择语言时按通用代码展示。
|
||||
*/
|
||||
export function codeBlockMarkup(): string {
|
||||
export type CodeLanguage = { value: string; label: string };
|
||||
|
||||
/** 常用语言;与前台 enlighter 的语言标识保持一致。 */
|
||||
export const CODE_LANGUAGES: CodeLanguage[] = [
|
||||
{ value: 'generic', label: __('通用', 'jio') },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'csharp', label: 'C#' },
|
||||
{ value: 'cpp', label: 'C++' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'bash', label: 'Shell' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
];
|
||||
|
||||
/** 语言值 → 展示名;未知值原样返回。 */
|
||||
export function codeLabel(language: string): string {
|
||||
return CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
||||
}
|
||||
|
||||
/** 代码块:语言写在 data 属性里,前台 enlighter 与编辑器标签共用。 */
|
||||
export function codeBlockMarkup(language = 'generic', code = ''): string {
|
||||
const body = code !== '' ? escapeHtml(code) : escapeHtml(__('在此输入代码', 'jio'));
|
||||
return (
|
||||
'<pre class="enlighter-pre"><code class="gl" data-enlighter-language="generic">' +
|
||||
escapeHtml(__('在此输入代码', 'jio')) +
|
||||
`<pre class="enlighter-pre" data-enlighter-language="${escapeHtml(language)}"><code class="gl" data-enlighter-language="${escapeHtml(language)}">` +
|
||||
body +
|
||||
'</code></pre><p></p>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格:3×3 带表头,方便作者直接在单元格里改写。
|
||||
*/
|
||||
export function tableMarkup(): string {
|
||||
const cell = (tag: string, text: string): string => `<${tag}>${escapeHtml(text)}</${tag}>`;
|
||||
const header = `<tr>${[1, 2, 3].map((i) => cell('th', `${__('列', 'jio')} ${i}`)).join('')}</tr>`;
|
||||
const rows = [1, 2]
|
||||
.map(() => `<tr>${[1, 2, 3].map(() => cell('td', '')).join('')}</tr>`)
|
||||
.join('');
|
||||
return `<table class="jio-content-table"><thead>${header}</thead><tbody>${rows}</tbody></table><p></p>`;
|
||||
export type TableOptions = {
|
||||
rows: number;
|
||||
cols: number;
|
||||
header: boolean;
|
||||
striped: boolean;
|
||||
};
|
||||
|
||||
/** 带尺寸参数的表格;首行可选表头,隔行变色写入 class。 */
|
||||
export function tableMarkup({ rows, cols, header, striped }: TableOptions): string {
|
||||
const totalRows = Math.max(1, Math.min(20, rows));
|
||||
const totalCols = Math.max(1, Math.min(10, cols));
|
||||
const cellsFor = (tag: 'th' | 'td'): string =>
|
||||
Array.from({ length: totalCols }, (_, column) =>
|
||||
`<${tag}>${tag === 'th' ? escapeHtml(`${__('列', 'jio')} ${column + 1}`) : ''}</${tag}>`
|
||||
).join('');
|
||||
|
||||
let body = '';
|
||||
if (header) {
|
||||
body += `<thead><tr>${cellsFor('th')}</tr></thead>`;
|
||||
}
|
||||
const bodyRows = Math.max(1, header ? totalRows - 1 : totalRows);
|
||||
body += `<tbody>${Array.from({ length: bodyRows }, () => `<tr>${cellsFor('td')}</tr>`).join('')}</tbody>`;
|
||||
|
||||
const classes = ['jio-content-table'];
|
||||
if (striped) {
|
||||
classes.push('is-striped');
|
||||
}
|
||||
return `<table class="${classes.join(' ')}">${body}</table><p></p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,15 +96,35 @@ export function tableMarkup(): string {
|
||||
* 因此这里只放一个占位标记,读者端不会看到裸露的标记文本。
|
||||
*/
|
||||
export function tocMarkup(): string {
|
||||
return `<div class="jio-toc-placeholder"></div><p></p>`;
|
||||
return '<div class="jio-toc-placeholder"></div><p></p>';
|
||||
}
|
||||
|
||||
/** 注释:Markdown 脚注语法,前台由文章渲染流程转成编号脚注。 */
|
||||
export function footnoteMarkup(): string {
|
||||
return '<p>[^1]<br>[^1]: ' + escapeHtml(__('注释内容', 'jio')) + '</p>';
|
||||
/** 注释:Markdown 脚注语法,前台渲染成编号脚注列表。 */
|
||||
export function footnoteMarkup(note: string): string {
|
||||
const text = note.trim() || __('注释内容', 'jio');
|
||||
return `<p>[^1]<br>[^1]: ${escapeHtml(text)}</p>`;
|
||||
}
|
||||
|
||||
/** 公式:行内 LaTeX,包在主题可识别的公式容器里。 */
|
||||
export function mathMarkup(): string {
|
||||
return `<span class="jio-math">E = mc^2</span>`;
|
||||
export type MathOptions = { formula: string; display: boolean };
|
||||
|
||||
/** 公式:行内或独占一行,数据保存在 .jio-math 容器里。 */
|
||||
export function mathMarkup({ formula, display }: MathOptions): string {
|
||||
const className = display ? 'jio-math jio-math--block' : 'jio-math';
|
||||
const body = `<span class="${className}">${escapeHtml(formula.trim())}</span>`;
|
||||
return display ? `<p>${body}</p>` : body;
|
||||
}
|
||||
|
||||
export type QuoteColor = '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv';
|
||||
|
||||
/**
|
||||
* 引用:沿用主题的 .quote_q 结构(.quote-mce 提供左侧引号标记),
|
||||
* 颜色通过主题已有的 qe_wzk_* 类切换。
|
||||
*/
|
||||
export function quoteMarkup(content: string, color: QuoteColor): string {
|
||||
const classes = ['quote_q', 'quote-mce'];
|
||||
if (color) {
|
||||
classes.push(color);
|
||||
}
|
||||
const inner = content.trim() !== '' ? `<p>${content}</p>` : '<p></p>';
|
||||
return `<div class="${classes.join(' ')}"><div>${inner}</div></div><p></p>`;
|
||||
}
|
||||
Reference in new issue
Block a user