feat(creator-studio): icon toolbar with block tools, drop author strip

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-06 22:30:08 +08:00
1 parent 7bdf7cf018
commit 7c33482be7
19 files changed
+600 -241

No files matched your search

@@ -1,40 +0,0 @@
/**
* 作者行:标题下方的头像 + 昵称(公众号式)。
*
* 数据来自服务端 bootstrap,前端只负责展示;没有作者信息时整行不渲染,
* 避免出现空头像占位。
*
* @package Jio
*/
import { __ } from '@wordpress/i18n';
type Author = {
name?: string;
avatar?: string;
meta?: string;
};
export function AuthorRow({ author }: { author: Author }) {
const name = typeof author?.name === 'string' ? author.name.trim() : '';
if (name === '') {
return null;
}
const avatar = typeof author?.avatar === 'string' ? author.avatar : '';
const meta = typeof author?.meta === 'string' ? author.meta : '';
return (
<div className="jio-creator__author">
{avatar !== '' ? (
<img className="jio-creator__author-avatar" src={avatar} alt="" width={24} height={24} />
) : (
<span className="jio-creator__author-avatar is-placeholder" aria-hidden="true">
{name.slice(0, 1)}
</span>
)}
<span className="jio-creator__author-name">{name}</span>
{meta !== '' && <span className="jio-creator__author-meta">{meta}</span>}
<span className="jio-creator__author-hint">{__('原创内容', 'jio')}</span>
</div>
);
}
@@ -1,3 +1,12 @@
/**
* 固定顶栏里的正文工具条。
*
* 每个按钮都是「上图标 + 下文字」,与公众号/知乎的单行工具栏一致;
* 只渲染当前账号真正有权限使用的能力,全部动作由父组件转给 TinyMCE。
*
* @package Jio
*/
import { __ } from '@wordpress/i18n';
type Capabilities = Record<string, boolean>;
@@ -12,9 +21,34 @@ type Props = {
onCommand: (name: string, value?: string) => void;
onRememberSelection: () => void;
onOpenLink: () => void;
onOpenMedia: (type: 'image' | 'video') => void;
onOpenMedia: (type: 'image' | 'video' | 'file') => void;
onInsertBlock: (kind: string) => void;
};
type ToolButtonProps = {
label: string;
icon: string;
ready: boolean;
onClick: () => void;
title?: string;
};
function ToolButton({ label, icon, ready, onClick, title }: ToolButtonProps) {
return (
<button
type="button"
className="jio-creator__tool-btn"
disabled={!ready}
onClick={onClick}
title={title || label}
aria-label={title || label}
>
<i className={icon} aria-hidden="true" />
<span className="jio-creator__tool-label">{label}</span>
</button>
);
}
export function EditorToolbar({
ready,
canUndo,
@@ -26,6 +60,7 @@ export function EditorToolbar({
onRememberSelection,
onOpenLink,
onOpenMedia,
onInsertBlock,
}: Props) {
return (
<div
@@ -38,58 +73,65 @@ export function EditorToolbar({
}}
>
<div className="jio-creator__toolbar-group" role="group" aria-label={__('撤销和重做', 'jio')}>
<button type="button" className="jio-creator__tool-btn" disabled={!canUndo} onClick={() => onCommand('undo')} title={__('撤销', 'jio')} aria-label={__('撤销', 'jio')}>
<i className="fa fas fa-undo" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!canRedo} onClick={() => onCommand('redo')} title={__('重做', 'jio')} aria-label={__('重做', 'jio')}>
<i className="fa fas fa-redo" aria-hidden="true" />
</button>
<ToolButton label={__('撤销', 'jio')} icon="fa fas fa-undo" ready={canUndo} onClick={() => onCommand('undo')} />
<ToolButton label={__('重做', 'jio')} icon="fa fas fa-redo" ready={canRedo} onClick={() => onCommand('redo')} />
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<select
className="jio-creator__format-select"
aria-label={__('段落样式', 'jio')}
value={formatValue}
disabled={!ready}
onFocus={onRememberSelection}
onChange={(event) => {
onFormatChange(event.target.value);
onCommand('format', event.target.value);
}}
>
<option value="p">{__('正文', 'jio')}</option>
<option value="h1">{__('标题 1', 'jio')}</option>
<option value="h2">{__('标题 2', 'jio')}</option>
<option value="h3">{__('标题 3', 'jio')}</option>
<option value="blockquote">{__('引用', 'jio')}</option>
</select>
<div className="jio-creator__toolbar-group" role="group" aria-label={__('段落样式', 'jio')}>
<label className="jio-creator__format-field">
<select
className="jio-creator__format-select"
aria-label={__('标题级别', 'jio')}
value={formatValue === 'blockquote' ? 'p' : formatValue}
disabled={!ready}
onFocus={onRememberSelection}
onChange={(event) => {
onFormatChange(event.target.value);
onCommand('format', event.target.value);
}}
>
<option value="p">{__('正文', 'jio')}</option>
<option value="h1">{__('标题 1', 'jio')}</option>
<option value="h2">{__('标题 2', 'jio')}</option>
<option value="h3">{__('标题 3', 'jio')}</option>
</select>
<i className="fa fas fa-heading" aria-hidden="true" />
<span className="jio-creator__tool-label">{__('标题', 'jio')}</span>
</label>
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<div className="jio-creator__toolbar-group" role="group" aria-label={__('文字格式', 'jio')}>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Bold')} title={__('加粗', 'jio')} aria-label={__('加粗', 'jio')}>
<i className="fa fas fa-bold" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Italic')} title={__('斜体', 'jio')} aria-label={__('斜体', 'jio')}>
<i className="fa fas fa-italic" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Underline')} title={__('下划线', 'jio')} aria-label={__('下划线', 'jio')}>
<i className="fa fas fa-underline" aria-hidden="true" />
</button>
<ToolButton label={__('加粗', 'jio')} icon="fa fas fa-bold" ready={ready} onClick={() => onCommand('Bold')} />
<ToolButton label={__('斜体', 'jio')} icon="fa fas fa-italic" ready={ready} onClick={() => onCommand('Italic')} />
<ToolButton label={__('下划线', 'jio')} icon="fa fas fa-underline" ready={ready} onClick={() => onCommand('Underline')} />
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<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')} />
<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')} />
<ToolButton label={__('公式', 'jio')} icon="fa fas fa-square-root-alt" ready={ready} onClick={() => onInsertBlock('math')} />
<ToolButton label={__('表格', 'jio')} icon="fa fas fa-table" ready={ready} onClick={() => onInsertBlock('table')} />
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<div className="jio-creator__toolbar-group" role="group" aria-label={__('媒体和链接', 'jio')}>
{capabilities.canUploadImage && (
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('image')} title={__('插入图片', 'jio')}>
<i className="fa far fa-image" aria-hidden="true" /><span>{__('图片', 'jio')}</span>
</button>
<ToolButton label={__('图片', 'jio')} icon="fa far fa-image" ready={ready} onClick={() => onOpenMedia('image')} />
)}
{(capabilities.canUploadVideo || capabilities.canEmbed) && (
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('video')} title={__('插入视频', 'jio')}>
<i className="fa fas fa-video" aria-hidden="true" /><span>{__('视频', 'jio')}</span>
</button>
<ToolButton label={__('视频', 'jio')} icon="fa fas fa-video" ready={ready} onClick={() => onOpenMedia('video')} />
)}
<ToolButton label={__('链接', 'jio')} icon="fa fas fa-link" ready={ready} onClick={onOpenLink} />
{capabilities.canUploadFile && (
<ToolButton label={__('附件', 'jio')} icon="fa fas fa-paperclip" ready={ready} onClick={() => onOpenMedia('file')} />
)}
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={onOpenLink} title={__('插入链接', 'jio')}>
<i className="fa fas fa-link" aria-hidden="true" /><span>{__('链接', 'jio')}</span>
</button>
</div>
</div>
);
+34 -14
View File
@@ -7,18 +7,18 @@
* @package Jio
*/
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { createRoot, useCallback, useEffect, 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 { AuthorRow } from './components/author-row';
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 './styles/studio.scss';
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
@@ -184,8 +184,6 @@ function Studio(): JSX.Element {
setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : 'p');
}, []);
const charCount = useMemo(() => Array.from(plainText(draft.content)).length, [draft.content]);
const buildPayload = useCallback((extra: Record<string, any> = {}) => {
const current = draftRef.current;
return {
@@ -510,7 +508,31 @@ function Studio(): JSX.Element {
}
}, [focusEditor]);
const openMedia = useCallback((type: 'image' | 'video') => {
const insertBlock = useCallback((kind: 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) {
return;
}
editor.insertContent(html);
editor.nodeChanged();
updateContent(editor.getContent());
updateHistoryAvailability(editor);
}, [focusEditor]);
const openMedia = useCallback((type: 'image' | 'video' | 'file') => {
const editor = editorRef.current;
const media = (window as any).JioSubmitMedia;
if (!editor || !media || typeof media.open !== 'function') {
@@ -535,6 +557,10 @@ function Studio(): JSX.Element {
const fullAttribute = fullUrl ? ` data-full-url="${escapeAttribute(fullUrl)}"` : '';
return `<p><img src="${escapeAttribute(url)}"${idAttribute} alt="${escapeAttribute(String(item.name || item.title || ''))}"${fullAttribute}></p>`;
}
if (type === 'file') {
const name = String(item.filename || item.name || url.split('/').pop() || '');
return `<p><a class="jio-file-link" href="${escapeAttribute(url)}"${idAttribute}><i class="fa fas fa-paperclip" aria-hidden="true"></i> ${escapeAttribute(name)}</a></p>`;
}
return `<div contenteditable="false" data-video-url="${escapeAttribute(url)}" data-video-name="${escapeAttribute(String(item.filename || item.name || url))}"${idAttribute} class="new-dplayer post-dplayer dplayer"></div><p></p>`;
}).join('');
if (markup) {
@@ -572,8 +598,8 @@ function Studio(): JSX.Element {
<EditorToolbar
ready={editorReady}
formatValue={formatValue}
onFormatChange={setFormatValue}
formatValue={formatValue}
onFormatChange={setFormatValue}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
capabilities={capabilities}
@@ -581,6 +607,7 @@ function Studio(): JSX.Element {
onRememberSelection={rememberSelection}
onOpenLink={openLink}
onOpenMedia={openMedia}
onInsertBlock={insertBlock}
/>
<div className="jio-creator__bar-actions jio-creator__toolbar-extension" data-toolbar-extension aria-label={__('工具扩展区', 'jio')}>
<SaveState value={saveState} />
@@ -618,8 +645,6 @@ function Studio(): JSX.Element {
)}
<main className="jio-creator__canvas">
<AuthorRow author={bootstrap.author || {}} />
<label className="jio-creator__title">
<span className="screen-reader-text">{__('文章标题', 'jio')}</span>
<textarea
@@ -651,11 +676,6 @@ function Studio(): JSX.Element {
onScheduleChange={(enabled, at) => patchDraft({ schedule: { enabled, at } })}
/>
<footer className="jio-creator__footer">
<span>{__('字数', 'jio')}:{charCount}</span>
<span>{__('约', 'jio')} {Math.max(1, Math.round(charCount / 400))} {__('分钟阅读', 'jio')}</span>
</footer>
{drawerOpen && (
<PublishDrawer
draft={draft}
+81 -98
View File
@@ -48,7 +48,7 @@ body.jio-page-submit.has-static-nav {
--jc-accent: #{$accent};
--jc-accent-strong: #{$accent-strong};
--jc-warn: #{$warn};
--jc-bar: 60px;
--jc-bar: 64px;
min-height: 100vh;
min-height: 100dvh;
@@ -172,25 +172,34 @@ body.jio-page-submit.has-static-nav {
background: var(--jc-line);
}
/* 工具按钮:图标在上、文字在下,一行高度固定,与公众号/知乎一致。 */
.jio-creator__tool-btn {
display: inline-flex;
min-width: 32px;
height: 34px;
width: auto;
min-width: 40px;
height: 46px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 8px;
flex-direction: column;
gap: 2px;
padding: 5px 6px;
border: 0;
border-radius: 7px;
border-radius: 6px;
background: transparent;
color: var(--jc-ink-soft);
color: #5f6664;
cursor: pointer;
font-size: 13px;
font-size: 11px;
line-height: 1.2;
i {
font-size: 14px;
line-height: 1;
}
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background: #edf3f0;
background: #f0f2f1;
color: var(--jc-accent-strong);
outline: 0;
}
@@ -201,31 +210,61 @@ body.jio-page-submit.has-static-nav {
}
}
.jio-creator__tool-labeled span {
.jio-creator__tool-label {
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jio-creator__format-select {
width: 94px;
height: 34px;
/* 标题按钮沿用「图标 + 文字」外观,内部叠一个原生的级别下拉。 */
.jio-creator__format-field {
position: relative;
display: inline-flex;
width: auto;
min-width: 48px;
height: 46px;
flex: 0 0 auto;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--jc-ink-soft);
font: inherit;
font-size: 13px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
padding: 5px 6px;
border-radius: 6px;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--jc-line);
background: #fff;
outline: 0;
i {
font-size: 14px;
line-height: 1;
}
&:hover,
&:focus-within {
background: #f0f2f1;
color: var(--jc-accent-strong);
}
&:focus-within {
outline: 2px solid rgba(38, 118, 101, 0.35);
outline-offset: -2px;
}
}
.jio-creator__format-select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
opacity: 0;
cursor: pointer;
&:disabled {
color: #aebbb5;
cursor: default;
}
}
@@ -475,7 +514,7 @@ body.jio-page-submit.has-static-nav {
.jio-creator__settings {
width: min(900px, calc(100% - clamp(24px, 6vw, 72px)));
margin: 20px auto 0;
margin: 20px auto clamp(48px, 10vh, 140px);
padding: clamp(20px, 3vw, 28px) clamp(24px, 4vw, 40px);
border-radius: 4px;
background: #fff;
@@ -638,70 +677,14 @@ body.jio-page-submit.has-static-nav {
}
}
.jio-creator__footer {
display: flex;
justify-content: space-between;
width: min(900px, calc(100% - clamp(24px, 6vw, 72px)));
margin: 16px auto 0;
padding: 0 4px 140px;
color: #98a19e;
font-size: 12px;
}
/* ---------- 作者行 ---------- */
.jio-creator__author {
display: flex;
align-items: center;
gap: 8px;
margin: 2px 0 20px;
color: #8c9a94;
font-size: 12px;
}
.jio-creator__author-avatar {
width: 24px;
height: 24px;
flex: 0 0 auto;
border-radius: 50%;
object-fit: cover;
}
.jio-creator__author-avatar.is-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
background: #e3ece7;
color: #4a5f57;
font-size: 11px;
font-weight: 600;
}
.jio-creator__author-name {
color: #4a5f57;
font-weight: 500;
}
.jio-creator__author-meta {
padding: 1px 6px;
border-radius: 4px;
background: #eef5f2;
color: #3c7d6c;
font-size: 11px;
}
.jio-creator__author-hint {
margin-left: auto;
color: #b0bcb7;
}
@import './_publish-drawer';
/* ---------- 手机 / 平板 ---------- */
@media (max-width: 767px) {
.jio-creator {
--jc-bar: 56px;
/* 图标 + 文字的两行按钮把顶栏撑高,移动端同样保留文字。 */
--jc-bar: 60px;
}
.jio-creator__bar {
@@ -736,17 +719,22 @@ body.jio-page-submit.has-static-nav {
}
.jio-creator__tool-btn {
min-width: 30px;
padding: 0 6px;
min-width: 36px;
padding: 4px 4px;
i {
font-size: 13px;
}
}
.jio-creator__tool-labeled span {
display: none;
.jio-creator__tool-label {
max-width: 42px;
font-size: 10px;
}
.jio-creator__format-select {
width: 82px;
padding: 0 5px;
.jio-creator__format-field {
min-width: 42px;
padding: 4px 4px;
}
.jio-creator__bar-actions .jio-creator__save {
@@ -788,7 +776,7 @@ body.jio-page-submit.has-static-nav {
.jio-creator__settings {
width: calc(100% - 20px);
margin-top: 12px;
margin: 12px auto calc(40px + env(safe-area-inset-bottom));
padding: 18px;
}
@@ -800,11 +788,6 @@ body.jio-page-submit.has-static-nav {
font-size: 24px;
}
.jio-creator__footer {
width: calc(100% - 20px);
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.jio-creator__editor .mce-edit-area iframe,
.jio-creator__editor .tox .tox-edit-area__iframe {
min-height: 48vh;
@@ -0,0 +1,66 @@
/**
* 正文块的 HTML 片段。
*
* 这些块都由服务端再次清洗(FrontSubmitSave 的 wp_kses_post),因此这里只负责
* 生成符合前台渲染习惯的结构:代码块沿用 enlighter 的既有高亮约定,脚注与公式
* 使用主题前台已有的渲染口径,目录使用文章目录树识别标题的 data-nav 结构。
*
* @package Jio
*/
import { __ } from '@wordpress/i18n';
const escapeHtml = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** 有序 / 无序列表:直接插入一个空列表块,光标落在第一项。 */
export function listMarkup(ordered: boolean): string {
const tag = ordered ? 'ol' : 'ul';
return `<${tag}><li></li></${tag}><p></p>`;
}
/** 分割线。 */
export function dividerMarkup(): string {
return '<hr><p></p>';
}
/**
* 代码块:沿用前台 enlighter 的高亮标记,未选择语言时按通用代码展示。
*/
export function codeBlockMarkup(): string {
return (
'<pre class="enlighter-pre"><code class="gl" data-enlighter-language="generic">' +
escapeHtml(__('在此输入代码', 'jio')) +
'</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>`;
}
/**
* 目录:文章页的目录树由前台 section-navs 依据 [data-nav] 生成,
* 因此这里只放一个占位标记,读者端不会看到裸露的标记文本。
*/
export function tocMarkup(): string {
return `<div class="jio-toc-placeholder"></div><p></p>`;
}
/** 注释:Markdown 脚注语法,前台由文章渲染流程转成编号脚注。 */
export function footnoteMarkup(): string {
return '<p>[^1]<br>[^1]: ' + escapeHtml(__('注释内容', 'jio')) + '</p>';
}
/** 公式:行内 LaTeX,包在主题可识别的公式容器里。 */
export function mathMarkup(): string {
return `<span class="jio-math">E = mc^2</span>`;
}