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:
kate522andClaude Opus 5 committed 2026-11-06 23:23:59 +08:00
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;
}
`;
+129 -17
View File
@@ -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';
/* ---------- 手机 / 平板 ---------- */
+89 -25
View File
@@ -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>`;
}