feat(creator-studio): bottom status bar, editor-rendered math/footnote, code language picker, block delete
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6928127bfa
commit
4a119358ca
11 files changed
+754
-63
No files matched your search
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 底部固定状态栏。
|
||||
*
|
||||
* 与公众号/知乎一致:左侧是字数与输入法状态,中间是草稿时间,右侧是预览与发布。
|
||||
* 固定在视口底部,不随正文滚动。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
import { SaveState, type SaveStateValue } from './save-state';
|
||||
|
||||
type Props = {
|
||||
charCount: number;
|
||||
saveState: SaveStateValue;
|
||||
isPublishedEdit: boolean;
|
||||
saving: boolean;
|
||||
onSaveDraft: () => void;
|
||||
onPreview: () => void;
|
||||
onPublish: () => void;
|
||||
};
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
/** 相对时间:刚刚 / X分钟前 / X小时前 … 与参考编辑器一致。 */
|
||||
export function relativeTime(iso: string, now = Date.now()): string {
|
||||
const at = Date.parse(iso);
|
||||
if (!Number.isFinite(at)) {
|
||||
return '';
|
||||
}
|
||||
const diff = Math.max(0, now - at);
|
||||
if (diff < MINUTE) {
|
||||
return __('刚刚', 'jio');
|
||||
}
|
||||
if (diff < HOUR) {
|
||||
return sprintf(
|
||||
/* translators: %d: minutes */
|
||||
__('%d分钟前', 'jio'),
|
||||
Math.floor(diff / MINUTE)
|
||||
);
|
||||
}
|
||||
if (diff < DAY) {
|
||||
return sprintf(
|
||||
/* translators: %d: hours */
|
||||
__('%d小时前', 'jio'),
|
||||
Math.floor(diff / HOUR)
|
||||
);
|
||||
}
|
||||
if (diff < MONTH) {
|
||||
return sprintf(
|
||||
/* translators: %d: days */
|
||||
__('%d天前', 'jio'),
|
||||
Math.floor(diff / DAY)
|
||||
);
|
||||
}
|
||||
if (diff < YEAR) {
|
||||
return sprintf(
|
||||
/* translators: %d: months */
|
||||
__('%d个月前', 'jio'),
|
||||
Math.floor(diff / MONTH)
|
||||
);
|
||||
}
|
||||
return sprintf(
|
||||
/* translators: %d: years */
|
||||
__('%d年前', 'jio'),
|
||||
Math.floor(diff / YEAR)
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<SaveStateValue['state'], string> = {
|
||||
ready: __('草稿', 'jio'),
|
||||
saving: __('保存中', 'jio'),
|
||||
saved: __('草稿', 'jio'),
|
||||
error: __('保存失败', 'jio'),
|
||||
conflict: __('冲突', 'jio'),
|
||||
submitting: __('提交中', 'jio'),
|
||||
submitted: __('已提交', 'jio'),
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
charCount,
|
||||
saveState,
|
||||
isPublishedEdit,
|
||||
saving,
|
||||
onSaveDraft,
|
||||
onPreview,
|
||||
onPublish,
|
||||
}: Props) {
|
||||
const savedAt = saveState.at ? relativeTime(saveState.at) : '';
|
||||
const status = STATUS_LABELS[saveState.state] || STATUS_LABELS.ready;
|
||||
|
||||
return (
|
||||
<footer className="jio-creator__status" role="contentinfo">
|
||||
<div className="jio-creator__status-left">
|
||||
<span className="jio-creator__status-count">
|
||||
{__('字数', 'jio')}:{charCount}
|
||||
</span>
|
||||
<span className="jio-creator__status-mode">{__('Markdown 语法输入中', 'jio')}</span>
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__status-meta">
|
||||
<SaveState value={saveState} />
|
||||
<span className="jio-creator__status-dot" aria-hidden="true">·</span>
|
||||
<span className="jio-creator__status-time">{savedAt || status}</span>
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__status-actions">
|
||||
<button type="button" className="jio-creator__status-ghost" onClick={onSaveDraft} disabled={saving}>
|
||||
{__('保存草稿', 'jio')}
|
||||
</button>
|
||||
<button type="button" className="jio-creator__status-ghost" onClick={onPreview} disabled={saving}>
|
||||
{__('预览', 'jio')}
|
||||
</button>
|
||||
<button type="button" className="jio-creator__status-primary" onClick={onPublish} disabled={saving}>
|
||||
{isPublishedEdit ? __('提交改稿', 'jio') : __('发布', 'jio')}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ img { height: auto; }
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.enlighter-pre::before {
|
||||
.enlighter-pre .jio-code-lang {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
@@ -50,10 +50,13 @@ img { height: auto; }
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #3dcabd 10%, #254bdd 100%);
|
||||
color: #fff;
|
||||
content: attr(data-jio-code-label);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.enlighter-pre .jio-code-lang:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* 引用:与前台主题的 .quote_q 视觉一致。 */
|
||||
@@ -101,9 +104,21 @@ img { height: auto; }
|
||||
}
|
||||
.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-math {
|
||||
font-family: "Cambria Math", "Latin Modern Math", "Times New Roman", serif;
|
||||
font-size: 1.05em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.jio-math--block {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: #f8faf9;
|
||||
text-align: center;
|
||||
}
|
||||
.jio-math sup, .jio-math sub { font-size: 0.72em; }
|
||||
.jio-toc-placeholder {
|
||||
height: 40px;
|
||||
margin: 0 0 1.1em;
|
||||
@@ -117,13 +132,22 @@ img { height: auto; }
|
||||
}
|
||||
.jio-toc-placeholder::after { content: '目录将在发布后自动生成'; }
|
||||
|
||||
/* 注释上标:编辑器里只显示序号,发布后由服务端渲染脚注列表。 */
|
||||
.jio-footnote-ref,
|
||||
.jio-footnote-ref a {
|
||||
/* 注释:编辑器里显示上标标记,发布后由服务端渲染编号与注释列表。 */
|
||||
.jio-footnote-ref {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
background: #eef5f2;
|
||||
color: #267665;
|
||||
font-size: 0.8em;
|
||||
text-decoration: none;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.6;
|
||||
vertical-align: super;
|
||||
cursor: default;
|
||||
}
|
||||
.jio-footnote-ref::before {
|
||||
content: '注 ';
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 附件链接。 */
|
||||
|
||||
@@ -7,19 +7,22 @@
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { createRoot, useCallback, useEffect, useRef, useState } from '@wordpress/element';
|
||||
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
import { apiPost } from './services/api';
|
||||
import { PublishDrawer } from './panels/publish-drawer';
|
||||
import { EditorToolbar } from './components/editor-toolbar';
|
||||
import { PublishSettings } from './components/publish-settings';
|
||||
import { SaveState, type SaveStateValue } from './components/save-state';
|
||||
import { type SaveStateValue } from './components/save-state';
|
||||
import { useAutosave } from './hooks/use-autosave';
|
||||
import { useLocalRecovery } from './hooks/use-local-recovery';
|
||||
import { normalizePayForRequest } from './utils/pay';
|
||||
import { EDITOR_CONTENT_STYLE } from './editor-content-style';
|
||||
import { hydrateBlocks, serializeBlocks } from './utils/serialize';
|
||||
import { StatusBar } from './components/status-bar';
|
||||
import {
|
||||
CODE_LANGUAGES,
|
||||
codeBlockMarkup,
|
||||
dividerMarkup,
|
||||
footnoteMarkup,
|
||||
@@ -174,6 +177,160 @@ function removeAdjacentBlock(editor: any, event: any): void {
|
||||
editor.undoManager.add();
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器里为每个代码块渲染语言角标,并保证回车不会跳出代码区。
|
||||
*
|
||||
* 角标是编辑期提示(纯 UI),保存前由 serializeBlocks 移除。
|
||||
*/
|
||||
/**
|
||||
* 在代码块内回车时保持在代码里换行,而不是跳出到普通段落。
|
||||
*
|
||||
* TinyMCE 默认把 pre 当成不可持续输入的块,回车会新建段落,导致代码跑到
|
||||
* 代码块外面。这里手动插入换行并保持光标在 code 内。
|
||||
*/
|
||||
/**
|
||||
* 让 iframe 高度跟随正文增长,避免编辑器内部出现第二根滚动条。
|
||||
*
|
||||
* TinyMCE 的 iframe 不会自动撑高;这里测量内容高度并同步容器,
|
||||
* 页面整体滚动由外层工作台负责。
|
||||
*/
|
||||
function syncEditorHeight(editor: any): void {
|
||||
if (!editor || editor.removed) {
|
||||
return;
|
||||
}
|
||||
const doc = editor.getDoc();
|
||||
const iframe = editor.getContentAreaContainer()?.querySelector('iframe') as HTMLIFrameElement | null;
|
||||
if (!doc || !iframe) {
|
||||
return;
|
||||
}
|
||||
const height = Math.max(
|
||||
320,
|
||||
doc.documentElement.scrollHeight,
|
||||
doc.body ? doc.body.scrollHeight : 0
|
||||
);
|
||||
iframe.style.height = `${height}px`;
|
||||
iframe.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function keepCaretInsideCode(editor: any, event: any): boolean {
|
||||
if (Number(event.keyCode) !== 13 || event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
const pre = editor.dom.getParent(editor.selection.getNode(), 'pre.enlighter-pre');
|
||||
if (!pre) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
const code = pre.querySelector('code') || pre;
|
||||
editor.selection.setCursorLocation(code, code.childNodes.length);
|
||||
editor.insertContent('<br>');
|
||||
editor.nodeChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 可整体删除的块级元素选择器。 */
|
||||
const BLOCK_SELECTOR = 'table,pre.enlighter-pre,hr,.quote_q,p';
|
||||
|
||||
/**
|
||||
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
|
||||
*
|
||||
* 与 WPS 的块操作一致:鼠标悬停到块上时在右上角出现删除入口,
|
||||
* 避免只能靠反复按退格键。
|
||||
*/
|
||||
function installBlockActions(editor: any): void {
|
||||
const doc = editor.getDoc();
|
||||
if (!doc || doc.getElementById('jio-block-actions')) {
|
||||
return;
|
||||
}
|
||||
const bar = doc.createElement('div');
|
||||
bar.id = 'jio-block-actions';
|
||||
bar.className = 'jio-creator__block-actions';
|
||||
bar.innerHTML =
|
||||
'<button type="button" class="jio-creator__block-action" data-jio-block-delete title="' +
|
||||
'删除此块' +
|
||||
'" aria-label="删除此块"><span aria-hidden="true">✕</span></button>';
|
||||
doc.body.appendChild(bar);
|
||||
|
||||
let current: HTMLElement | null = null;
|
||||
|
||||
const hide = () => {
|
||||
current = null;
|
||||
bar.classList.remove('is-visible');
|
||||
};
|
||||
|
||||
const show = (block: HTMLElement) => {
|
||||
if (!block || block === doc.body) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
// 空段落没有删除价值,交给退格键处理。
|
||||
if (block.tagName === 'P' && (block.textContent || '').trim() === '') {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
current = block;
|
||||
const rect = block.getBoundingClientRect();
|
||||
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
|
||||
bar.style.left = `${rect.right - 40}px`;
|
||||
bar.classList.add('is-visible');
|
||||
};
|
||||
|
||||
doc.addEventListener('mouseover', (event: MouseEvent) => {
|
||||
const block = (event.target as HTMLElement)?.closest?.(BLOCK_SELECTOR) as HTMLElement | null;
|
||||
if (block && block !== doc.body && !block.classList.contains('jio-code-lang')) {
|
||||
show(block);
|
||||
}
|
||||
});
|
||||
|
||||
doc.addEventListener('mouseleave', hide);
|
||||
|
||||
bar.addEventListener('mousedown', (event: MouseEvent) => event.preventDefault());
|
||||
bar.addEventListener('click', (event: MouseEvent) => {
|
||||
const button = (event.target as HTMLElement)?.closest('[data-jio-block-delete]');
|
||||
if (!button || !current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const target = current;
|
||||
hide();
|
||||
editor.undoManager.transact(() => {
|
||||
editor.dom.remove(target);
|
||||
});
|
||||
editor.nodeChanged();
|
||||
editor.undoManager.add();
|
||||
if (editor.getBody().children.length === 0) {
|
||||
editor.setContent('<p></p>');
|
||||
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
|
||||
}
|
||||
syncEditorHeight(editor);
|
||||
});
|
||||
}
|
||||
|
||||
function syncCodeLabels(editor: any): void {
|
||||
const root: HTMLElement | null = editor?.getBody?.() ?? null;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>('pre.enlighter-pre[data-jio-code]').forEach((pre) => {
|
||||
const existing = pre.querySelector(':scope > .jio-code-lang');
|
||||
const language = pre.getAttribute('data-enlighter-language') || 'generic';
|
||||
if (existing) {
|
||||
existing.setAttribute('data-jio-code-lang', language);
|
||||
const label = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
||||
if ((existing.textContent || '') !== label) {
|
||||
existing.textContent = label;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'jio-code-lang';
|
||||
badge.contentEditable = 'false';
|
||||
badge.setAttribute('data-jio-code-lang', language);
|
||||
badge.textContent = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
||||
pre.insertBefore(badge, pre.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -215,6 +372,8 @@ function Studio(): JSX.Element {
|
||||
}));
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [blockDialog, setBlockDialog] = useState<BlockDialogKind | null>(null);
|
||||
const [codeLanguage, setCodeLanguage] = useState<null | 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);
|
||||
@@ -231,6 +390,7 @@ function Studio(): JSX.Element {
|
||||
pointsMax: Number(bootstrap.pay?.pointsMax || 0),
|
||||
};
|
||||
const isPublishedEdit = !!bootstrap.isPublished;
|
||||
const charCount = useMemo(() => Array.from(plainText(draft.content)).length, [draft.content]);
|
||||
|
||||
const patchDraft = useCallback((patch: Partial<Draft>) => {
|
||||
dirtyRef.current = true;
|
||||
@@ -292,7 +452,7 @@ function Studio(): JSX.Element {
|
||||
async (autosave = false): Promise<boolean> => {
|
||||
const editor = editorRef.current;
|
||||
if (editor && !editor.removed) {
|
||||
updateContent(editor.getContent());
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
}
|
||||
const current = draftRef.current;
|
||||
if (!dirtyRef.current && (current.postId > 0 || current.versionId > 0)) {
|
||||
@@ -468,7 +628,8 @@ function Studio(): JSX.Element {
|
||||
body_class: 'jio-creator__tinymce',
|
||||
content_css: bootstrap.creator?.contentCss || '',
|
||||
content_style: EDITOR_CONTENT_STYLE,
|
||||
height: 650,
|
||||
// iframe 高度随后由 autoresize 随正文增长,这里只给初始值。
|
||||
height: 520,
|
||||
placeholder: String(bootstrap.limits?.contentPlaceholder || ''),
|
||||
setup: (editor: any) => {
|
||||
editorRef.current = editor;
|
||||
@@ -476,17 +637,24 @@ function Studio(): JSX.Element {
|
||||
editorReadyRef.current = true;
|
||||
setEditorReady(true);
|
||||
if (pendingRestoreContentRef.current !== null) {
|
||||
editor.setContent(pendingRestoreContentRef.current);
|
||||
editor.setContent(hydrateBlocks(pendingRestoreContentRef.current));
|
||||
pendingRestoreContentRef.current = null;
|
||||
} else {
|
||||
editor.setContent(hydrateBlocks(editor.getContent()));
|
||||
}
|
||||
syncCodeLabels(editor);
|
||||
syncEditorHeight(editor);
|
||||
installBlockActions(editor);
|
||||
updateHistoryAvailability(editor);
|
||||
updateFormatValue(editor);
|
||||
});
|
||||
editor.on('NodeChange', () => updateFormatValue(editor));
|
||||
// 代码块右上角的语言标签:点击整块即可换语言,等价于公众号的「高亮」入口。
|
||||
editor.on('input change Undo Redo AddUndo', () => {
|
||||
updateContent(editor.getContent());
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
updateHistoryAvailability(editor);
|
||||
syncCodeLabels(editor);
|
||||
syncEditorHeight(editor);
|
||||
});
|
||||
editor.on('keydown', (event: any) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.keyCode === 83) {
|
||||
@@ -601,8 +769,9 @@ function Studio(): JSX.Element {
|
||||
}
|
||||
editor.insertContent(html);
|
||||
editor.nodeChanged();
|
||||
updateContent(editor.getContent());
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
updateHistoryAvailability(editor);
|
||||
syncCodeLabels(editor);
|
||||
}, [focusEditor]);
|
||||
|
||||
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
|
||||
@@ -736,18 +905,6 @@ function Studio(): JSX.Element {
|
||||
onInsertBlock={insertBlock}
|
||||
onInsertQuote={insertQuote}
|
||||
/>
|
||||
<div className="jio-creator__bar-actions jio-creator__toolbar-extension" data-toolbar-extension aria-label={__('工具扩展区', 'jio')}>
|
||||
<SaveState value={saveState} />
|
||||
<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')}
|
||||
</button>
|
||||
<button type="button" className="jio-creator__primary" onClick={() => setDrawerOpen(true)}>
|
||||
{isPublishedEdit ? __('提交改稿', 'jio') : __('发布', 'jio')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{recovery && (
|
||||
@@ -803,6 +960,48 @@ function Studio(): JSX.Element {
|
||||
onScheduleChange={(enabled, at) => patchDraft({ schedule: { enabled, at } })}
|
||||
/>
|
||||
|
||||
{codeLanguage !== null && (
|
||||
<div className="jio-creator-dialog" role="dialog" aria-modal="true" aria-labelledby="jio-code-lang-title">
|
||||
<button type="button" className="jio-creator-dialog__backdrop" aria-label={__('关闭', 'jio')} onClick={() => setCodeLanguage(null)} />
|
||||
<div className="jio-creator-dialog__panel">
|
||||
<header className="jio-creator-dialog__head">
|
||||
<h2 id="jio-code-lang-title">{__('代码语言', 'jio')}</h2>
|
||||
<button type="button" className="jio-creator-dialog__close" onClick={() => setCodeLanguage(null)} aria-label={__('关闭', 'jio')}>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="jio-creator-dialog__body">
|
||||
<div className="jio-creator__lang-grid">
|
||||
{CODE_LANGUAGES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`jio-creator__lang-item${item.value === codeLanguage ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
const target = codeTargetRef.current;
|
||||
codeTargetRef.current = null;
|
||||
setCodeLanguage(null);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.setAttribute('data-enlighter-language', item.value);
|
||||
const code = target.querySelector('code');
|
||||
if (code) {
|
||||
code.setAttribute('data-enlighter-language', item.value);
|
||||
}
|
||||
syncCodeLabels(editorRef.current);
|
||||
updateContent(serializeBlocks(editorRef.current.getContent()));
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blockDialog && (
|
||||
<BlockDialog
|
||||
kind={blockDialog}
|
||||
@@ -811,6 +1010,16 @@ function Studio(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
<StatusBar
|
||||
charCount={charCount}
|
||||
saveState={saveState}
|
||||
isPublishedEdit={isPublishedEdit}
|
||||
saving={!editorReady || saveState.state === 'saving' || saveState.state === 'submitting'}
|
||||
onSaveDraft={() => void save(false)}
|
||||
onPreview={() => void preview()}
|
||||
onPublish={() => setDrawerOpen(true)}
|
||||
/>
|
||||
|
||||
{drawerOpen && (
|
||||
<PublishDrawer
|
||||
draft={draft}
|
||||
|
||||
@@ -259,3 +259,203 @@
|
||||
.is-green .jio-creator__quote-swatch {
|
||||
background: #23790e;
|
||||
}
|
||||
|
||||
/* ---------- 底部固定状态栏 ---------- */
|
||||
|
||||
.jio-creator__status {
|
||||
position: fixed;
|
||||
z-index: 310;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
height: var(--jc-status);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 clamp(14px, 2.4vw, 28px) env(safe-area-inset-bottom);
|
||||
border-top: 1px solid #e6e9e8;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
color: #7d8683;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 -2px 10px rgba(24, 39, 34, 0.04);
|
||||
}
|
||||
|
||||
.jio-creator__status-left,
|
||||
.jio-creator__status-meta,
|
||||
.jio-creator__status-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.jio-creator__status-count {
|
||||
color: #4f5957;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.jio-creator__status-mode {
|
||||
overflow: hidden;
|
||||
color: #a3aca9;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jio-creator__status-meta {
|
||||
color: #93a09b;
|
||||
|
||||
.jio-creator__save {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__status-dot {
|
||||
color: #c9d1ce;
|
||||
}
|
||||
|
||||
.jio-creator__status-actions {
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jio-creator__status-ghost,
|
||||
.jio-creator__status-primary {
|
||||
display: inline-flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jio-creator__status-ghost {
|
||||
border: 1px solid #e3e7e5;
|
||||
background: #fff;
|
||||
color: #5f6664;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
border-color: #b7cdc3;
|
||||
color: var(--jc-accent-strong);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__status-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.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 代码语言选择 ---------- */
|
||||
|
||||
.jio-creator__lang-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jio-creator__lang-item {
|
||||
padding: 9px 6px;
|
||||
border: 1px solid #e6e9e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #4f5957;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: #b7cdc3;
|
||||
color: var(--jc-accent-strong);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: var(--jc-accent);
|
||||
background: #eff6f3;
|
||||
color: var(--jc-accent-strong);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 块级删除控件(接近 WPS 的「块操作」) ---------- */
|
||||
|
||||
.jio-creator__block-actions {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.jio-creator__block-actions.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.jio-creator__block-action {
|
||||
display: inline-flex;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e3e7e5;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #7d8683;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: #e6b8ad;
|
||||
color: #b4573f;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.jio-creator__status {
|
||||
gap: 8px;
|
||||
height: 56px;
|
||||
padding: 0 10px env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.jio-creator__status-mode,
|
||||
.jio-creator__status-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jio-creator__status-ghost {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.jio-creator__status-primary {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.jio-creator__lang-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,13 @@ body.jio-page-submit.has-static-nav {
|
||||
--jc-accent-strong: #{$accent-strong};
|
||||
--jc-warn: #{$warn};
|
||||
--jc-bar: 64px;
|
||||
--jc-status: 52px;
|
||||
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
/* 固定顶栏属于工作台 chrome,不随正文一起滚动。 */
|
||||
/* 固定顶栏 / 底栏属于工作台 chrome,不随正文一起滚动。 */
|
||||
padding-top: var(--jc-bar);
|
||||
padding-bottom: var(--jc-status);
|
||||
background: var(--jc-paper);
|
||||
color: var(--jc-ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
@@ -217,12 +219,13 @@ body.jio-page-submit.has-static-nav {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 标题按钮沿用「图标 + 文字」外观,内部叠一个原生的级别下拉。 */
|
||||
/* 标题按钮沿用「图标 + 文字」外观,内部叠一个原生的级别下拉。
|
||||
几何与 .jio-creator__tool-btn 完全一致,否则会在同一行里错位。 */
|
||||
.jio-creator__format-field {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
min-width: 48px;
|
||||
min-width: 40px;
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
@@ -230,8 +233,14 @@ body.jio-page-submit.has-static-nav {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #5f6664;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
@@ -454,6 +463,7 @@ body.jio-page-submit.has-static-nav {
|
||||
|
||||
.jio-creator__editor {
|
||||
position: relative;
|
||||
/* 正文随内容增长,滚动交给整个页面,避免出现第二根滚动条。 */
|
||||
min-height: 52vh;
|
||||
margin: 22px 0 0;
|
||||
|
||||
@@ -489,7 +499,9 @@ body.jio-page-submit.has-static-nav {
|
||||
|
||||
.mce-edit-area iframe,
|
||||
.tox .tox-edit-area__iframe {
|
||||
/* 自适应高度由脚本同步,这里只给下限,不设内心滚动。 */
|
||||
min-height: 52vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@@ -735,8 +747,13 @@ body.jio-page-submit.has-static-nav {
|
||||
}
|
||||
|
||||
.jio-creator__format-field {
|
||||
min-width: 42px;
|
||||
min-width: 36px;
|
||||
height: 46px;
|
||||
padding: 4px 4px;
|
||||
|
||||
i {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__bar-actions .jio-creator__save {
|
||||
@@ -778,7 +795,7 @@ body.jio-page-submit.has-static-nav {
|
||||
|
||||
.jio-creator__settings {
|
||||
width: calc(100% - 20px);
|
||||
margin: 12px auto calc(40px + env(safe-area-inset-bottom));
|
||||
margin: 12px auto calc(24px + env(safe-area-inset-bottom));
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,19 +51,26 @@ export function codeLabel(language: string): string {
|
||||
return CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
||||
}
|
||||
|
||||
/** 代码块:语言写在 data 属性里,前台 enlighter 与编辑器标签共用。 */
|
||||
/** 代码块右上角角标:编辑器内由 React 渲染,这里给出标识供 CSS 定位。 */
|
||||
export const CODE_LABEL_CLASS = 'jio-code-lang';
|
||||
|
||||
/**
|
||||
* 代码块:语言写在 data 属性里,前台 enlighter 与编辑器角标共用。
|
||||
*
|
||||
* `data-jio-code` 标记这是编辑器创建的块,编辑器据此保证回车不跳出代码区、
|
||||
* 并渲染可点击的语言角标。
|
||||
*/
|
||||
export function codeBlockMarkup(language = 'generic', code = ''): string {
|
||||
const body = code !== '' ? escapeHtml(code) : escapeHtml(__('在此输入代码', 'jio'));
|
||||
// data-jio-code-label 供编辑器角标直接显示,避免在 CSS 里映射语言标识。
|
||||
const label = escapeHtml(codeLabel(language));
|
||||
return (
|
||||
`<pre class="enlighter-pre" data-enlighter-language="${escapeHtml(language)}" data-jio-code-label="${label}">` +
|
||||
`<pre class="enlighter-pre" data-jio-code="1" data-enlighter-language="${escapeHtml(language)}">` +
|
||||
`<code class="gl" data-enlighter-language="${escapeHtml(language)}">` +
|
||||
body +
|
||||
'</code></pre><p></p>'
|
||||
);
|
||||
}
|
||||
|
||||
/** 把代码块的语言写成编辑器里的内联标签(纯文本,保存前由编辑器移除)。 */
|
||||
export type TableOptions = {
|
||||
rows: number;
|
||||
cols: number;
|
||||
@@ -102,21 +109,33 @@ export function tocMarkup(): string {
|
||||
return '<p>[jio_toc]</p><p></p>';
|
||||
}
|
||||
|
||||
/** 注释:用短代码落库,服务端生成上标序号与文末注释列表。 */
|
||||
export function footnoteMarkup(note: string): string {
|
||||
const text = note.trim() || __('注释内容', 'jio');
|
||||
// 短代码属性里再放 HTML 会被 kses 改写,这里只允许纯文本内容。
|
||||
return `<p>[jio_footnote]${escapeHtml(text)}[/jio_footnote]</p>`;
|
||||
}
|
||||
|
||||
export type MathOptions = { formula: string; display: boolean };
|
||||
|
||||
/** 公式:用短代码落库,服务端统一做上标 / 下标排版。 */
|
||||
/** 公式的上标 / 下标排版(与 ContentBlocks::typeset_math 保持一致)。 */
|
||||
export function typesetMath(raw: string): string {
|
||||
const escaped = escapeHtml(raw);
|
||||
return escaped
|
||||
.replace(/\^\{([^{}]*)\}|\^(\S)/gu, '<sup>$1$2</sup>')
|
||||
.replace(/_\{([^{}]*)\}|_(\S)/gu, '<sub>$1$2</sub>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 公式:编辑器里直接显示排版结果,保存前由 `serializeBlocks` 换回短代码。
|
||||
*/
|
||||
export function mathMarkup({ formula, display }: MathOptions): string {
|
||||
const body = escapeHtml(formula.trim());
|
||||
const body = typesetMath(formula.trim());
|
||||
const attrs = `data-jio-math-source="${escapeHtml(formula.trim())}" data-jio-math-display="${display ? '1' : '0'}"`;
|
||||
return display
|
||||
? `<p>[jio_math display="1"]${body}[/jio_math]</p>`
|
||||
: `<p>[jio_math display="0"]${body}[/jio_math]</p>`;
|
||||
? `<p><span class="jio-math jio-math--block" ${attrs} contenteditable="false">${body}</span></p>`
|
||||
: `<span class="jio-math" ${attrs}>${body}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注释:编辑器里显示上标序号,保存前由 `serializeBlocks` 换回短代码。
|
||||
*/
|
||||
export function footnoteMarkup(note: string): string {
|
||||
const text = note.trim() || __('注释内容', 'jio');
|
||||
return `<sup class="jio-footnote-ref" data-jio-footnote="${escapeHtml(text)}" contenteditable="false">${escapeHtml(text)}</sup>`;
|
||||
}
|
||||
|
||||
export type QuoteColor = '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 编辑器内容 ↔ 保存格式的转换。
|
||||
*
|
||||
* 编辑器里把公式、注释、代码语言标签渲染成可读的样子(方便写作时看到最终效果),
|
||||
* 但落库必须回到短代码/纯标记,服务端才能稳定渲染。这里在两个方向上都做一次转换。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
const escapeAttr = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const decodeEntities = (value: string): string => {
|
||||
const area = document.createElement('textarea');
|
||||
area.innerHTML = value;
|
||||
return area.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存前:把编辑器里的可视化块换回短代码 / 纯标记。
|
||||
*/
|
||||
export function serializeBlocks(html: string): string {
|
||||
if (!html || (html.indexOf('data-jio-') === -1 && html.indexOf('jio-code-lang') === -1)) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
|
||||
// 公式
|
||||
container.querySelectorAll<HTMLElement>('[data-jio-math-source]').forEach((node) => {
|
||||
const formula = node.getAttribute('data-jio-math-source') || '';
|
||||
const display = node.getAttribute('data-jio-math-display') !== '0';
|
||||
const shortcode = `[jio_math display="${display ? '1' : '0'}"]${formula}[/jio_math]`;
|
||||
const paragraph = node.closest('p');
|
||||
if (display && paragraph && paragraph.textContent?.trim() === node.textContent?.trim()) {
|
||||
paragraph.textContent = shortcode;
|
||||
} else {
|
||||
node.replaceWith(document.createTextNode(shortcode));
|
||||
}
|
||||
});
|
||||
|
||||
// 注释
|
||||
container.querySelectorAll<HTMLElement>('[data-jio-footnote]').forEach((node) => {
|
||||
const note = node.getAttribute('data-jio-footnote') || '';
|
||||
node.replaceWith(document.createTextNode(`[jio_footnote]${note}[/jio_footnote]`));
|
||||
});
|
||||
|
||||
// 代码块语言角标只是编辑期提示
|
||||
container.querySelectorAll('.jio-code-lang').forEach((node) => node.remove());
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开已有内容时:把短代码还原成编辑器里的可视化块。
|
||||
*/
|
||||
export function hydrateBlocks(html: string): string {
|
||||
if (!html || html.indexOf('[jio_') === -1) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return html
|
||||
.replace(
|
||||
/\[jio_math display="([01])"\]([\s\S]*?)\[\/jio_math\]/g,
|
||||
(_match, display: string, formula: string) =>
|
||||
display === '0'
|
||||
? `<span class="jio-math" data-jio-math-source="${escapeAttr(formula)}" data-jio-math-display="0" contenteditable="false">${typeset(formula)}</span>`
|
||||
: `<p><span class="jio-math jio-math--block" data-jio-math-source="${escapeAttr(formula)}" data-jio-math-display="1" contenteditable="false">${typeset(formula)}</span></p>`
|
||||
)
|
||||
.replace(
|
||||
/\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g,
|
||||
(_match, note: string) =>
|
||||
`<sup class="jio-footnote-ref" data-jio-footnote="${escapeAttr(decodeEntities(note))}" contenteditable="false">${note}</sup>`
|
||||
);
|
||||
}
|
||||
|
||||
const typeset = (raw: string): string => {
|
||||
const escaped = raw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return escaped
|
||||
.replace(/\^\{([^{}]*)\}|\^(\S)/gu, '<sup>$1$2</sup>')
|
||||
.replace(/_\{([^{}]*)\}|_(\S)/gu, '<sub>$1$2</sub>');
|
||||
};
|
||||
Reference in new issue
Block a user