feat(creator-studio): portal dropdowns, hidden content, two-step block delete
Toolbar - New ToolMenu renders menus through a portal on body, so they are no longer clipped by the horizontally scrolling toolbar and no longer fall back to a native <select>. Triggers reuse .jio-creator__tool-btn geometry, fixing the heading button's mismatched height/size/position. - Heading / list / align / quote / hidden-content are all dropdowns. - Lists: unordered + ordered via InsertOrderedList/InsertUnorderedList. The TOC button is removed (it also used the wrong icon). - Adds left / center / right alignment. - Adds hidden content: comment / login / VIP / paid, wired to the existing [reply], [jio_hide mode="login"], [vip] and [payshow] shortcodes. Editor content - Quote blocks now match the live article: full .quote_q structure, four colour variables, and an SVG-mask quote mark that does not depend on the Font Awesome font being present inside the iframe. - Block math is centred on a transparent background instead of a full-width grey bar. - Code blocks are plain <pre><code>, identical to the article page; language and theme selection is gone and EnlighterJS handles highlighting globally. The classic submit editor's code modal follows the same shape. - Hidden content reuses the .tinymce-hide structure and shows a readable label while keeping the shortcode text in the DOM for saving. Block deletion - Two-step delete, WPS style: the first click only selects and highlights the block, the second click removes it. Clicking elsewhere or Esc cancels. Contracts updated and extended; 884 widget-system tests pass.
This commit is contained in:
1 parent
3e48fdf61e
commit
c5bdbc0457
16 files changed
+1090
-545
No files matched your search
@@ -4,12 +4,16 @@
|
||||
* 每个按钮都是「上图标 + 下文字」,与公众号/知乎的单行工具栏一致;
|
||||
* 只渲染当前账号真正有权限使用的能力,全部动作由父组件转给 TinyMCE。
|
||||
*
|
||||
* 需要多选项的工具(标题级别、列表、引用配色、隐藏内容)统一走 ToolMenu,
|
||||
* 避免原生 <select> 的风格断裂和滚动容器裁剪。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
import { ToolMenu, type ToolMenuItem } from './tool-menu';
|
||||
|
||||
type Capabilities = Record<string, boolean>;
|
||||
|
||||
type Props = {
|
||||
@@ -18,6 +22,8 @@ type Props = {
|
||||
canRedo: boolean;
|
||||
formatValue: string;
|
||||
onFormatChange: (value: string) => void;
|
||||
alignValue: string;
|
||||
blockValue: string;
|
||||
capabilities: Capabilities;
|
||||
onCommand: (name: string, value?: string) => void;
|
||||
onRememberSelection: () => void;
|
||||
@@ -25,6 +31,7 @@ type Props = {
|
||||
onOpenMedia: (type: 'image' | 'video' | 'file') => void;
|
||||
onInsertBlock: (kind: string) => void;
|
||||
onInsertQuote: (color: '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv') => void;
|
||||
onInsertHidden: (kind: 'reply' | 'login' | 'vip' | 'payshow') => void;
|
||||
};
|
||||
|
||||
type ToolButtonProps = {
|
||||
@@ -33,17 +40,19 @@ type ToolButtonProps = {
|
||||
ready: boolean;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
function ToolButton({ label, icon, ready, onClick, title }: ToolButtonProps) {
|
||||
function ToolButton({ label, icon, ready, onClick, title, active = false }: ToolButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="jio-creator__tool-btn"
|
||||
className={`jio-creator__tool-btn${active ? ' is-active' : ''}`}
|
||||
disabled={!ready}
|
||||
onClick={onClick}
|
||||
title={title || label}
|
||||
aria-label={title || label}
|
||||
aria-pressed={active || undefined}
|
||||
>
|
||||
<i className={icon} aria-hidden="true" />
|
||||
<span className="jio-creator__tool-label">{label}</span>
|
||||
@@ -51,12 +60,30 @@ function ToolButton({ label, icon, ready, onClick, title }: ToolButtonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 引用配色:菜单项自带色块预览,和正文里的效果对得上。 */
|
||||
const QUOTE_OPTIONS = [
|
||||
{ key: '' as const, label: __('灰色引用', 'jio'), swatch: '#555555' },
|
||||
{ key: 'qe_wzk_c-red' as const, label: __('红色引用', 'jio'), swatch: '#dc3f3f' },
|
||||
{ key: 'qe_wzk_lan' as const, label: __('蓝色引用', 'jio'), swatch: '#0092ff' },
|
||||
{ key: 'qe_wzk_lv' as const, label: __('绿色引用', 'jio'), swatch: '#23790e' },
|
||||
];
|
||||
|
||||
/** 隐藏内容的四档可见性,对应服务端的 [reply] / [jio_hide] / [vip] / [payshow]。 */
|
||||
const HIDE_OPTIONS = [
|
||||
{ key: 'reply' as const, label: __('评论后可查看', 'jio'), icon: 'fa fas fa-comment-dots' },
|
||||
{ key: 'login' as const, label: __('登录后可查看', 'jio'), icon: 'fa fas fa-sign-in-alt' },
|
||||
{ key: 'vip' as const, label: __('会员可查看', 'jio'), icon: 'fa fas fa-crown' },
|
||||
{ key: 'payshow' as const, label: __('付费后可查看', 'jio'), icon: 'fa fas fa-coins' },
|
||||
];
|
||||
|
||||
export function EditorToolbar({
|
||||
ready,
|
||||
canUndo,
|
||||
canRedo,
|
||||
formatValue,
|
||||
onFormatChange,
|
||||
alignValue,
|
||||
blockValue,
|
||||
capabilities,
|
||||
onCommand,
|
||||
onRememberSelection,
|
||||
@@ -64,32 +91,64 @@ export function EditorToolbar({
|
||||
onOpenMedia,
|
||||
onInsertBlock,
|
||||
onInsertQuote,
|
||||
onInsertHidden,
|
||||
}: Props) {
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const quoteRef = useRef<HTMLDivElement | null>(null);
|
||||
const headingItems: ToolMenuItem[] = [
|
||||
{ key: 'p', label: __('正文', 'jio'), icon: 'fa fas fa-paragraph' },
|
||||
{ key: 'h2', label: __('标题 2', 'jio'), icon: 'fa fas fa-heading' },
|
||||
{ key: 'h3', label: __('标题 3', 'jio'), icon: 'fa fas fa-heading' },
|
||||
{ key: 'h4', label: __('标题 4', 'jio'), icon: 'fa fas fa-heading' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
active: formatValue === item.key,
|
||||
onSelect: () => {
|
||||
onFormatChange(item.key);
|
||||
onCommand('format', item.key);
|
||||
},
|
||||
}));
|
||||
|
||||
// 点击别处或按 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]);
|
||||
const listItems: ToolMenuItem[] = [
|
||||
{ key: 'ul', label: __('无序列表', 'jio'), icon: 'fa fas fa-list-ul' },
|
||||
{ key: 'ol', label: __('有序列表', 'jio'), icon: 'fa fas fa-list-ol' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
active: blockValue === item.key,
|
||||
onSelect: () => onInsertBlock(item.key),
|
||||
}));
|
||||
|
||||
const alignItems: ToolMenuItem[] = [
|
||||
{ key: 'left', label: __('居左对齐', 'jio'), icon: 'fa fas fa-align-left' },
|
||||
{ key: 'center', label: __('居中对齐', 'jio'), icon: 'fa fas fa-align-center' },
|
||||
{ key: 'right', label: __('居右对齐', 'jio'), icon: 'fa fas fa-align-right' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
active: alignValue === item.key,
|
||||
onSelect: () => onCommand('align', item.key),
|
||||
}));
|
||||
|
||||
const quoteItems: ToolMenuItem[] = QUOTE_OPTIONS.map((option) => ({
|
||||
key: option.key || 'default',
|
||||
label: option.label,
|
||||
swatch: option.swatch,
|
||||
active: blockValue === (option.key || 'quote'),
|
||||
onSelect: () => onInsertQuote(option.key),
|
||||
}));
|
||||
|
||||
const hideItems: ToolMenuItem[] = HIDE_OPTIONS.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
onSelect: () => onInsertHidden(option.key),
|
||||
}));
|
||||
|
||||
// 触发器图标跟随当前状态(对齐方式 / 列表类型),一眼能看出光标所在段落。
|
||||
const alignIcon =
|
||||
alignValue === 'center'
|
||||
? 'fa fas fa-align-center'
|
||||
: alignValue === 'right'
|
||||
? 'fa fas fa-align-right'
|
||||
: 'fa fas fa-align-left';
|
||||
const listIcon = blockValue === 'ol' ? 'fa fas fa-list-ol' : 'fa fas fa-list-ul';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -108,26 +167,14 @@ export function EditorToolbar({
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
|
||||
<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>
|
||||
<ToolMenu
|
||||
label={__('标题', 'jio')}
|
||||
icon="fa fas fa-heading"
|
||||
items={headingItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
/>
|
||||
</div>
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
|
||||
@@ -139,35 +186,45 @@ export function EditorToolbar({
|
||||
<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')} />
|
||||
<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>
|
||||
<ToolMenu
|
||||
label={__('列表', 'jio')}
|
||||
icon={listIcon}
|
||||
items={listItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
active={blockValue === 'ul' || blockValue === 'ol'}
|
||||
/>
|
||||
<ToolMenu
|
||||
label={__('对齐', 'jio')}
|
||||
icon={alignIcon}
|
||||
items={alignItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
active={alignValue !== 'left'}
|
||||
title={__('对齐方式', 'jio')}
|
||||
/>
|
||||
<ToolMenu
|
||||
label={__('引用', 'jio')}
|
||||
icon="fa fas fa-quote-left"
|
||||
items={quoteItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
active={blockValue === 'quote' || QUOTE_OPTIONS.some((option) => option.key === blockValue)}
|
||||
/>
|
||||
{capabilities.canHide && (
|
||||
<ToolMenu
|
||||
label={__('隐藏', 'jio')}
|
||||
icon="fa fas fa-eye-slash"
|
||||
items={hideItems}
|
||||
ready={ready}
|
||||
width={190}
|
||||
onRememberSelection={onRememberSelection}
|
||||
title={__('隐藏内容', 'jio')}
|
||||
/>
|
||||
)}
|
||||
<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')} />
|
||||
|
||||
Reference in new issue
Block a user