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:
kate522 committed 2026-11-09 15:03:50 +08:00
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')} />
@@ -0,0 +1,182 @@
/**
* 工具栏下拉控件。
*
* 工具条本身横向滚动(`overflow-x: auto`),如果菜单直接绝对定位在按钮下方,
* 会被滚动容器裁掉、也只能复用生硬的原生 <select>。这里统一:
* - 触发器与 ToolButton 共用同一套几何(同一行高、同一文字层级);
* - 菜单通过 portal 挂到 document.body,按触发器位置 fixed 定位,永不被裁剪;
* - 打开 / 关闭有过渡,点击外部、Esc、滚动都会收起。
*
* @package Jio
*/
import { createPortal, useEffect, useLayoutEffect, useRef, useState } from '@wordpress/element';
export type ToolMenuItem = {
key: string;
label: string;
icon?: string;
/** 当前是否已应用(菜单里显示勾选态)。 */
active?: boolean;
/** 副标题:用于图标 + 主标题 + 说明的三段式菜单。 */
hint?: string;
/** 色块:引用 / 隐藏内容之类需要预览颜色的菜单项。 */
swatch?: string;
onSelect: () => void;
};
type Props = {
label: string;
icon: string;
items: ToolMenuItem[];
ready: boolean;
title?: string;
/** 触发器是否显示激活态(例如光标已在引用块内)。 */
active?: boolean;
/** 菜单宽度(px)。 */
width?: number;
onRememberSelection?: () => void;
};
type Position = { left: number; top: number; placement: 'bottom' | 'top' };
const MENU_GAP = 8;
export function ToolMenu({
label,
icon,
items,
ready,
title,
active = false,
width = 216,
onRememberSelection,
}: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<Position>({ left: 0, top: 0, placement: 'bottom' });
// 打开时按触发器矩形定位;优先向下展开,下方放不下时翻到上方。
useLayoutEffect(() => {
if (!open) {
return;
}
const update = () => {
const trigger = triggerRef.current;
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const menuHeight = menuRef.current?.offsetHeight || 240;
const placement: Position['placement'] =
rect.bottom + MENU_GAP + menuHeight > window.innerHeight && rect.top > menuHeight
? 'top'
: 'bottom';
const left = Math.min(
Math.max(MENU_GAP, rect.left + rect.width / 2 - width / 2),
Math.max(MENU_GAP, window.innerWidth - width - MENU_GAP)
);
const top = placement === 'bottom' ? rect.bottom + MENU_GAP : rect.top - menuHeight - MENU_GAP;
setPosition({ left, top, placement });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [open, width]);
useEffect(() => {
if (!open) {
return undefined;
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) {
return;
}
setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
const onScroll = () => setOpen(false);
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
window.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('scroll', onScroll, true);
};
}, [open]);
const menu = open
? createPortal(
<div
ref={menuRef}
className={`jio-creator__menu is-${position.placement}`}
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${width}px` }}
role="menu"
aria-label={title || label}
>
{items.map((item) => (
<button
key={item.key}
type="button"
role="menuitemradio"
aria-checked={item.active ? 'true' : 'false'}
className={`jio-creator__menu-item${item.active ? ' is-active' : ''}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.swatch ? (
<span
className="jio-creator__menu-swatch"
style={{ background: item.swatch }}
aria-hidden="true"
/>
) : (
<i className={`${item.icon || 'fa fas fa-check'} jio-creator__menu-icon`} aria-hidden="true" />
)}
<span className="jio-creator__menu-text">
<span className="jio-creator__menu-label">{item.label}</span>
{item.hint && <span className="jio-creator__menu-hint">{item.hint}</span>}
</span>
<i className="fa fas fa-check jio-creator__menu-check" aria-hidden="true" />
</button>
))}
</div>,
document.body
)
: null;
return (
<div className="jio-creator__tool-menu">
<button
ref={triggerRef}
type="button"
className={`jio-creator__tool-btn${active ? ' is-active' : ''}${open ? ' is-open' : ''}`}
disabled={!ready}
onClick={() => {
onRememberSelection?.();
setOpen((value) => !value);
}}
title={title || label}
aria-label={title || label}
aria-haspopup="menu"
aria-expanded={open ? 'true' : 'false'}
>
<i className={icon} aria-hidden="true" />
<span className="jio-creator__tool-label">{label}</span>
<i className="fa fas fa-caret-down jio-creator__tool-caret" aria-hidden="true" />
</button>
{menu}
</div>
);
}
+137 -69
View File
@@ -2,8 +2,8 @@
* TinyMCE iframe 内的正文样式。
*
* 编辑器正文跑在 iframe 里,页面级 SCSS 影响不到它,所以这里显式给出:
* 正文排版、主题引用 .quote_q、表格、公式、目录占位、块级删除控件,以及
* 代码块右上角的语言标签。
* 正文排版、主题引用 .quote_q、隐藏内容 .tinymce-hide、表格、公式、
* 块级删除控件与块选中态。
*
* @package Jio
*/
@@ -25,16 +25,14 @@ img { height: auto; }
/*
* 块级删除控件。它由 editor-runtime 插进 iframe 的 body,样式必须写在这里:
* 父页面样式表管不到 iframe 内部,缺这条规则时按钮会退化成正文顶部的常显 ✕。
* 父页面样式表管不到 iframe 内部,缺这条规则时按钮会退化成正文顶部的常显小方块。
* 正文是流式布局且 iframe 高度随内容增长,所以按整篇正文定位,而不是相对某个块。
*/
#jio-block-actions {
position: absolute;
z-index: 5;
top: 6px;
right: 6px;
display: flex;
gap: 6px;
left: 6px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
@@ -45,59 +43,72 @@ img { height: auto; }
}
#jio-block-actions .jio-creator__block-action {
display: inline-flex;
width: 26px;
height: 26px;
align-items: center;
justify-content: center;
padding: 0;
gap: 5px;
padding: 0 9px;
border: 1px solid #e3e7e5;
border-radius: 6px;
background: #fff;
color: #7d8683;
cursor: pointer;
font: inherit;
font-size: 12px;
line-height: 1;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(24, 39, 34, 0.08);
cursor: pointer;
}
#jio-block-actions .jio-creator__block-action-icon {
font-size: 11px;
line-height: 1;
}
#jio-block-actions .jio-creator__block-action:hover {
border-color: #e6b8ad;
border-color: #cdd6d2;
color: #4f5957;
}
#jio-block-actions.is-selected .jio-creator__block-action {
border-color: #e0a99b;
background: #fdf2ee;
color: #b4573f;
}
#jio-block-actions.is-selected .jio-creator__block-action:hover {
border-color: #cf8574;
background: #fbe7e1;
}
/* 代码块:右上角显示当前语言,点击即可更换(交互在父组件里接管)。 */
.enlighter-pre {
position: relative;
/* 第一次点击选中的块:虚线高亮,明确「还没删」。 */
.jio-creator__block-selected {
outline: 2px dashed rgba(180, 87, 63, 0.55);
outline-offset: 3px;
border-radius: 4px;
}
/*
* 代码块:与前台文章页保持一致,只用 pre > code。
* 前台由 EnlighterJS 高亮,编辑器里给出同一份底色与等宽排版。
*/
pre {
margin: 0 0 1.1em;
padding: 40px 14px 14px;
overflow: auto;
padding: 14px 18px;
overflow-x: auto;
border-radius: 8px;
background: #f2f4f5;
cursor: pointer;
color: #2b2b2b;
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
.enlighter-pre code.gl {
display: block;
pre code {
border: 0;
background: transparent;
color: #2b2b2b;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
}
.enlighter-pre .jio-code-lang {
position: absolute;
top: 9px;
right: 9px;
padding: 3px 10px;
border-radius: 4px;
background: linear-gradient(135deg, #3dcabd 10%, #254bdd 100%);
color: #fff;
font-size: 12px;
line-height: 1.5;
cursor: pointer;
user-select: none;
}
.enlighter-pre .jio-code-lang:hover {
filter: brightness(1.08);
color: inherit;
font-family: inherit;
font-size: inherit;
line-height: inherit;
padding: 0;
white-space: inherit;
}
/* 引用:与前台主题的 .quote_q 视觉一致。 */
@@ -106,24 +117,49 @@ img { height: auto; }
margin: 10px 0 20px;
padding: 1.2em;
border-radius: 6px;
color: var(--quote-color, #555);
background: rgba(85, 85, 85, 0.04);
--quote-color: #555;
color: var(--quote-color);
}
.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 {
.quote_q::before {
position: absolute;
top: 18px;
left: 18px;
color: var(--quote-color, #555);
content: '\\201C';
font-size: 1.4em;
inset: 0;
border-radius: 6px;
background: var(--quote-color);
content: '';
opacity: 0.04;
}
.quote_q.quote-mce::after {
position: absolute;
top: 20px;
left: 20px;
width: 1em;
height: 1em;
color: var(--quote-color);
content: '';
font-size: 1.2em;
line-height: 1;
opacity: 0.6;
/* 引号用图标字体绘制时依赖 FA;这里用内联 SVG 数据,编辑器与前台都不缺图标。 */
background: var(--quote-color);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M464 256h-80v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8c-88.4 0-160 71.6-160 160v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-288 0H96v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8C71.6 32 0 103.6 0 192v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M464 256h-80v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8c-88.4 0-160 71.6-160 160v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-288 0H96v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8C71.6 32 0 103.6 0 192v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.quote_q > div { padding-left: 2em; opacity: 0.85; }
.quote_q > div > p:last-child { margin-bottom: 0; }
.quote_q > div {
position: relative;
min-height: 24px;
padding-left: 2em;
opacity: 0.7;
}
.quote_q > div > :first-child { margin-top: 0; }
.quote_q > div > :last-child { margin-bottom: 0; }
.quote_q [contenteditable="true"] {
outline: 0;
border-radius: 0;
box-shadow: none;
}
.quote_q.qe_wzk_c-red { --quote-color: #dc3f3f; }
.quote_q.qe_wzk_lan { --quote-color: #0092ff; }
.quote_q.qe_wzk_lv { --quote-color: #23790e; }
/* 表格。 */
.jio-content-table {
@@ -145,7 +181,7 @@ 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;
font-size: 1.05em;
@@ -153,25 +189,57 @@ img { height: auto; }
}
.jio-math--block {
display: block;
margin: 12px 0;
padding: 10px;
border-radius: 6px;
background: #f8faf9;
max-width: 100%;
margin: 0 auto;
padding: 2px 0;
overflow-x: auto;
text-align: center;
background: transparent;
border: 0;
}
.jio-math sup, .jio-math sub { font-size: 0.72em; }
.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;
/* 隐藏内容:短代码放进 .hide-before / .hide-after,编辑器只显示可读标签。 */
.tinymce-hide {
position: relative;
margin: 18px 0;
padding: 38px 14px 14px;
border: 1px dashed rgba(240, 68, 148, 0.35);
border-radius: 6px;
background: rgba(240, 68, 148, 0.04);
}
.jio-toc-placeholder::after { content: '目录将在发布后自动生成'; }
.tinymce-hide > .hide-before,
.tinymce-hide > .hide-after {
margin: 0;
font-family: Consolas, Monaco, monospace;
font-size: 0;
line-height: 1.5;
color: #f04494;
}
.tinymce-hide > .hide-before {
position: absolute;
top: 10px;
left: 14px;
}
.tinymce-hide > .hide-before::before {
content: attr(data-jio-hide-label);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
.tinymce-hide > .hide-after {
display: none;
}
.tinymce-hide > [contenteditable="true"] {
min-height: 38px;
padding: 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.75);
outline: 0;
}
.tinymce-hide > [contenteditable="true"] > :first-child { margin-top: 0; }
.tinymce-hide > [contenteditable="true"] > :last-child { margin-bottom: 0; }
/* 注释:编辑器里显示上标标记,发布后由服务端渲染编号与注释列表。 */
.jio-footnote-ref {
+131 -64
View File
@@ -1,18 +1,16 @@
/**
* TinyMCE 编辑器增强:块级删除、代码块语言角标、自适应高度。
* TinyMCE 编辑器增强:块级两步删除、自适应高度。
*
* 这些行为都作用在编辑器 iframe 内部,与 React 状态无关,因此独立成模块。
*
* @package Jio
*/
import { CODE_LANGUAGES } from './utils/block-markup';
/** 可整体删除的块级元素选择器。 */
const BLOCK_SELECTOR = 'table,pre.enlighter-pre,hr,.quote_q,p';
const BLOCK_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,p';
/** 可整体删除的块级元素(排除普通段落)。 */
const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder';
/** 结构块(不能从内部被拆开的块)。 */
const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,.jio-math--block';
/**
* Backspace / Delete 删除相邻的块级元素。
@@ -108,41 +106,14 @@ export function syncEditorHeight(editor: any): void {
iframe.style.overflow = 'hidden';
}
/**
* 在编辑器里为每个代码块渲染语言角标。
*
* 角标是编辑期提示(纯 UI),保存前由 serializeBlocks 移除。
*/
export 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';
const label = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
if (existing) {
existing.setAttribute('data-jio-code-lang', 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 = label;
pre.insertBefore(badge, pre.firstChild);
});
}
/** 块选中状态写在块自身的 class 上,跨 iframe 重排也能保留。 */
const SELECTED_CLASS = 'jio-creator__block-selected';
/**
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
*
* 与 WPS 的块操作一致:鼠标悬停到块上时在右上角出现删除入口,
* 避免只能靠反复按退格键。
* 与 WPS 一致的两步删除:第一次点击只是选中该块并高亮,第二次点击才真正删除。
* 选中态下按钮改为醒目的红色「再次点击删除」,且鼠标移开不会消失,避免误删。
*
* 浮层直接插在 iframe 的 body 里,因此用 data-mce-bogus 让 TinyMCE 在
* 序列化、字数和选区计算时完全忽略它;它的样式写在 editor-content-style.ts
@@ -159,62 +130,158 @@ export function installBlockActions(editor: any): void {
// "all" 让 TinyMCE 连同子节点一起跳过。
bar.setAttribute('data-mce-bogus', 'all');
bar.innerHTML =
'<button type="button" class="jio-creator__block-action" data-jio-block-delete title="' +
'删除此块' +
'" aria-label="删除此块"><span aria-hidden="true">&#10005;</span></button>';
'<button type="button" class="jio-creator__block-action" data-jio-block-delete>' +
'<span class="jio-creator__block-action-icon" aria-hidden="true">&#10005;</span>' +
'<span class="jio-creator__block-action-text"></span>' +
'</button>';
doc.body.appendChild(bar);
let current: HTMLElement | null = null;
const button: HTMLButtonElement | null = bar.querySelector('[data-jio-block-delete]');
const text: HTMLElement | null = bar.querySelector('.jio-creator__block-action-text');
let hovered: HTMLElement | null = null;
let selected: HTMLElement | null = null;
const hide = () => {
current = null;
bar.classList.remove('is-visible');
/** 底部状态栏以外的提示文字。 */
const labelFor = (state: 'idle' | 'selected'): string =>
state === 'selected' ? '再次点击删除此块' : '选中此块';
const paint = (state: 'idle' | 'selected'): void => {
bar.classList.toggle('is-selected', state === 'selected');
if (text) {
text.textContent = labelFor(state);
}
const label = labelFor(state);
button?.setAttribute('title', label);
button?.setAttribute('aria-label', label);
};
const show = (block: HTMLElement) => {
const place = (block: HTMLElement): void => {
const rect = block.getBoundingClientRect();
// 右上角贴住块,但不越出正文左边界(窄块时按钮会溢出)。
const left = Math.max(rect.left + 8, rect.right - 96);
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
bar.style.left = `${left + doc.documentElement.scrollLeft}px`;
};
const show = (block: HTMLElement | null): void => {
if (!block || block === doc.body) {
hide();
bar.classList.remove('is-visible');
return;
}
// 空段落没有删除价值,交给退格键处理。
if (block.tagName === 'P' && (block.textContent || '').trim() === '') {
hide();
if (block.tagName === 'P' && (block.textContent || '').trim() === '' && !block.querySelector('img,iframe,table,pre')) {
bar.classList.remove('is-visible');
return;
}
current = block;
const rect = block.getBoundingClientRect();
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
bar.style.left = `${rect.right - 40}px`;
hovered = block;
place(block);
bar.classList.add('is-visible');
};
const deselect = (): void => {
if (selected) {
selected.classList.remove(SELECTED_CLASS);
}
selected = null;
paint('idle');
if (hovered) {
show(hovered);
} else {
bar.classList.remove('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);
if (!block || block === doc.body) {
return;
}
// 选中块时保持浮层贴在选中块上,不受悬停目标影响。
if (selected) {
return;
}
show(block);
});
doc.addEventListener('mouseleave', hide);
doc.addEventListener('mouseleave', () => {
hovered = null;
if (selected) {
place(selected);
return;
}
bar.classList.remove('is-visible');
});
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) {
const onClick = (event: MouseEvent): void => {
const target = (event.target as HTMLElement)?.closest('[data-jio-block-delete]');
const block = selected || hovered;
if (!target || !block) {
return;
}
event.preventDefault();
const target = current;
hide();
if (selected !== block) {
// 第一次点击:只选中,不删除。
if (selected) {
selected.classList.remove(SELECTED_CLASS);
}
selected = block;
block.classList.add(SELECTED_CLASS);
paint('selected');
place(block);
bar.classList.add('is-visible');
return;
}
// 第二次点击:真正删除。
const victim = block;
const parent = victim.parentElement;
victim.classList.remove(SELECTED_CLASS);
selected = null;
hovered = null;
bar.classList.remove('is-visible', 'is-selected');
paint('idle');
editor.undoManager.transact(() => {
editor.dom.remove(target);
editor.dom.remove(victim);
});
editor.nodeChanged();
editor.undoManager.add();
if (editor.getBody().children.length === 0) {
editor.setContent('<p></p>');
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
}
if (parent && parent.children.length === 0) {
editor.setContent('<p></p>');
}
const first = editor.getBody().firstChild;
if (first) {
editor.selection.setCursorLocation(first, 0);
}
editor.nodeChanged();
editor.undoManager.add();
syncEditorHeight(editor);
};
bar.addEventListener('click', onClick);
// 点击正文其它位置或按 Esc 取消选中态。
doc.addEventListener('mousedown', (event: MouseEvent) => {
if (bar.contains(event.target as Node)) {
return;
}
if (selected) {
deselect();
}
});
doc.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape' && selected) {
deselect();
}
});
// 内容被撤销 / 重排后,被选中的块可能已不在文档里。
editor.on('Undo Redo SetContent', () => {
if (selected && !doc.body.contains(selected)) {
selected = null;
paint('idle');
bar.classList.remove('is-visible', 'is-selected');
}
});
}
+126 -31
View File
@@ -26,11 +26,12 @@ import {
codeBlockMarkup,
dividerMarkup,
footnoteMarkup,
listMarkup,
hiddenContentMarkup,
mathMarkup,
quoteMarkup,
retargetHiddenContent,
tableMarkup,
tocMarkup,
type HideKind,
type QuoteColor,
} from './utils/block-markup';
import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog';
@@ -38,7 +39,6 @@ import { type Draft, type Limits, initialDraft } from './draft';
import {
installBlockActions,
removeAdjacentBlock,
syncCodeLabels,
syncEditorHeight,
} from './editor-runtime';
import './styles/studio.scss';
@@ -83,6 +83,8 @@ function Studio(): JSX.Element {
const [editorReady, setEditorReady] = useState(false);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const [formatValue, setFormatValue] = useState('p');
const [alignValue, setAlignValue] = useState('left');
const [blockValue, setBlockValue] = useState('p');
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
at: '',
@@ -137,9 +139,39 @@ function Studio(): JSX.Element {
const updateFormatValue = useCallback((editor: any) => {
const node = editor?.selection?.getNode();
const block = node ? editor.dom.getParent(node, 'p,h1,h2,h3,blockquote') || node : null;
const block = node ? editor.dom.getParent(node, 'p,h1,h2,h3,h4,h5,h6') || node : null;
const name = block?.nodeName?.toLowerCase() || 'p';
setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : 'p');
setFormatValue(['h1', 'h2', 'h3', 'h4'].includes(name) ? name : 'p');
}, []);
/**
* 同步工具栏的激活态:对齐方式、列表类型、以及光标是否在引用 / 隐藏内容里。
* 只读编辑器状态,不写 DOM,所以可以安全地在 NodeChange 上高频调用。
*/
const updateBlockValue = useCallback((editor: any) => {
const node = editor?.selection?.getNode();
if (!node) {
return;
}
const body = editor.getBody();
const alignment = String(editor.formatter?.match?.(['alignleft', 'aligncenter', 'alignright']) || '');
setAlignValue(alignment === 'aligncenter' ? 'center' : alignment === 'alignright' ? 'right' : 'left');
const quote = editor.dom.getParent(node, '.quote_q', body);
if (quote) {
const color = Array.from(quote.classList as unknown as string[]).find((name) => name.indexOf('qe_wzk_') === 0);
setBlockValue(color || 'quote');
return;
}
if (editor.dom.getParent(node, 'ul', body)) {
setBlockValue('ul');
return;
}
if (editor.dom.getParent(node, 'ol', body)) {
setBlockValue('ol');
return;
}
setBlockValue('p');
}, []);
const buildPayload = useCallback((extra: Record<string, any> = {}) => {
@@ -359,18 +391,19 @@ function Studio(): JSX.Element {
} else {
editor.setContent(hydrateBlocks(editor.getContent()));
}
syncCodeLabels(editor);
syncEditorHeight(editor);
installBlockActions(editor);
updateHistoryAvailability(editor);
updateFormatValue(editor);
updateBlockValue(editor);
});
editor.on('NodeChange', () => {
updateFormatValue(editor);
updateBlockValue(editor);
});
editor.on('NodeChange', () => updateFormatValue(editor));
// 代码块右上角的语言标签:点击整块即可换语言,等价于公众号的「高亮」入口。
editor.on('input change Undo Redo AddUndo', () => {
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncCodeLabels(editor);
syncEditorHeight(editor);
});
editor.on('keydown', (event: any) => {
@@ -391,7 +424,7 @@ function Studio(): JSX.Element {
editorReadyRef.current = false;
editorRef.current = null;
};
}, [updateContent]);
}, [updateContent, updateBlockValue, updateFormatValue]);
const restore = useCallback(() => {
try {
@@ -450,17 +483,19 @@ function Studio(): JSX.Element {
} else if (name === 'redo') {
editor.undoManager.redo();
} else if (name === 'format') {
if (value === 'p') {
editor.execCommand('FormatBlock', false, 'p');
} else {
editor.formatter.toggle(value || 'h2');
}
const block = value || 'p';
editor.execCommand('FormatBlock', false, block);
updateFormatValue(editor);
} else if (name === 'align') {
editor.execCommand(
value === 'center' ? 'JustifyCenter' : value === 'right' ? 'JustifyRight' : 'JustifyLeft'
);
updateBlockValue(editor);
} else {
editor.execCommand(name);
}
editor.nodeChanged();
}, [focusEditor]);
}, [focusEditor, updateBlockValue, updateFormatValue]);
const openLink = useCallback(() => {
const editor = focusEditor();
@@ -485,52 +520,109 @@ function Studio(): JSX.Element {
return;
}
editor.insertContent(html);
// 让刚插入的块成为一次可撤销的操作(insertContent 本身不进 undo 栈)。
editor.undoManager.add();
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncCodeLabels(editor);
syncEditorHeight(editor);
}, [focusEditor]);
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
const insertBlock = useCallback((kind: string) => {
rememberSelection();
// code 走「语言选择」弹窗,其余三类走通用插入弹窗。
if (kind === 'table' || kind === 'math' || kind === 'footnote') {
setBlockDialog(kind as BlockDialogKind);
return;
}
if (kind === 'code') {
// 直接插入;语言可稍后点代码块右上角的角标更换。
insertHtml(codeBlockMarkup('generic'));
insertHtml(codeBlockMarkup());
return;
}
if (kind === 'list') {
insertHtml(listMarkup(false));
return;
}
if (kind === 'toc') {
insertHtml(tocMarkup());
if (kind === 'ul' || kind === 'ol') {
const editor = focusEditor();
if (editor) {
editor.execCommand(kind === 'ol' ? 'InsertOrderedList' : 'InsertUnorderedList');
editor.undoManager.add();
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateBlockValue(editor);
}
return;
}
if (kind === 'divider') {
insertHtml(dividerMarkup());
}
}, [insertHtml, rememberSelection]);
}, [focusEditor, insertHtml, rememberSelection, updateBlockValue]);
/**
* 把当前选区(或光标所在的顶层段落)替换成一个整块。
*
* - 有选区:包住选中的内容;
* - 光标停在顶层段落里:整段替换,避免正文被复制成两份;
* - 其它位置:插入一个空块。
*/
const wrapSelection = useCallback((build: (innerHtml: string) => string) => {
const editor = focusEditor();
if (!editor) {
return;
}
const selected = String(editor.selection.getContent({ format: 'html' }) || '').trim();
if (selected === '') {
const node = editor.selection.getNode();
if (node && node.nodeName === 'P' && node.parentNode === editor.getBody()) {
// 选中整段后再插入,让 TinyMCE 用新块替换掉原段落。
editor.selection.select(node);
}
}
const inner = String(editor.selection.getContent({ format: 'html' }) || '').trim();
// insertContent 不进 undo 栈,这里包一层 transact 让整次插入可一次撤销。
editor.undoManager.transact(() => {
editor.insertContent(build(inner));
});
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncEditorHeight(editor);
updateBlockValue(editor);
}, [focusEditor, updateBlockValue]);
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}` : ''}`;
const classes = ['quote_q', 'quote-mce'];
if (colorClass) {
classes.push(colorClass);
}
existing.className = classes.join(' ');
editor.nodeChanged();
updateBlockValue(editor);
updateContent(serializeBlocks(editor.getContent()));
return;
}
insertHtml(quoteMarkup(selected, colorClass));
}, [focusEditor, insertHtml]);
wrapSelection((inner) => quoteMarkup(inner, colorClass));
}, [focusEditor, updateBlockValue, wrapSelection]);
const insertHidden = useCallback((kind: HideKind) => {
const editor = focusEditor();
if (!editor) {
return;
}
// 已选中隐藏块:换可见性档位,正文保持不变。
const existing = editor.dom.getParent(editor.selection.getNode(), '.tinymce-hide') as HTMLElement | null;
if (existing) {
retargetHiddenContent(existing, kind);
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
return;
}
wrapSelection((inner) => hiddenContentMarkup(kind, inner));
}, [focusEditor, wrapSelection]);
const applyBlockDialog = useCallback((result: BlockDialogResult) => {
setBlockDialog(null);
@@ -608,6 +700,8 @@ function Studio(): JSX.Element {
ready={editorReady}
formatValue={formatValue}
onFormatChange={setFormatValue}
alignValue={alignValue}
blockValue={blockValue}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
capabilities={capabilities}
@@ -617,6 +711,7 @@ function Studio(): JSX.Element {
onOpenMedia={openMedia}
onInsertBlock={insertBlock}
onInsertQuote={insertQuote}
onInsertHidden={insertHidden}
/>
</header>
</div>
@@ -1,5 +1,6 @@
/**
* 通用插入弹窗 + 引用样式菜单 + 正文块(表格 / 代码 / 公式)的编辑器内样式。
* 通用插入弹窗 + 正文块(表格 / 代码 / 公式)的编辑器内样式。
* 工具栏下拉菜单的样式在 studio.scss 里(菜单 portal 到 body,不受本文件作用域影响)。
*/
/* ---------- 通用插入弹窗 ---------- */
@@ -197,69 +198,6 @@
}
}
/* ---------- 引用样式菜单 ---------- */
.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;
}
/* ---------- 底部固定状态栏 ---------- */
.jio-creator__status {
+153 -57
View File
@@ -259,6 +259,17 @@ body.jio-page-submit.has-static-nav {
outline: 0;
}
/* 已应用的格式(光标在引用 / 居中段落里)用浅底常显。 */
&.is-active:not(:disabled) {
background: #e9f1ee;
color: var(--jc-accent-strong);
}
&.is-open:not(:disabled) {
background: #e9f1ee;
color: var(--jc-accent-strong);
}
&:disabled {
color: #c7d0cc;
cursor: default;
@@ -272,61 +283,156 @@ body.jio-page-submit.has-static-nav {
white-space: nowrap;
}
/* 标题按钮沿用「图标 + 文字」外观,内部叠一个原生的级别下拉。
几何与 .jio-creator__tool-btn 完全一致,否则会在同一行里错位。 */
.jio-creator__format-field {
/* 下拉触发器右下角的小三角:告诉作者这里还有更多选项。 */
.jio-creator__tool-caret {
position: absolute;
right: 3px;
bottom: 4px;
color: #b0b9b6;
font-size: 8px;
line-height: 1;
}
.jio-creator__tool-menu {
position: relative;
display: inline-flex;
width: auto;
min-width: 40px;
height: 46px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
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;
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__tool-btn {
position: relative;
}
}
.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;
/* ---------- 工具栏下拉菜单(portal 到 body,避免被滚动容器裁剪) ---------- */
&:disabled {
cursor: default;
.jio-creator__menu {
position: fixed;
z-index: 620;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid rgba(24, 39, 34, 0.06);
border-radius: 12px;
background: #fff;
box-shadow: 0 14px 40px rgba(20, 32, 28, 0.16), 0 2px 6px rgba(20, 32, 28, 0.06);
animation: jio-menu-in 0.12s ease-out;
}
.jio-creator__menu.is-top {
animation-name: jio-menu-in-up;
}
@keyframes jio-menu-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes jio-menu-in-up {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.jio-creator__menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: #4f5957;
cursor: pointer;
font: inherit;
font-size: 13px;
line-height: 1.4;
text-align: left;
&:hover,
&:focus-visible {
background: #f2f5f3;
color: var(--jc-accent-strong);
outline: 0;
}
&.is-active {
background: #eef4f1;
color: var(--jc-accent-strong);
}
}
.jio-creator__menu-icon {
width: 16px;
flex: 0 0 16px;
color: #8b9491;
font-size: 13px;
line-height: 1;
text-align: center;
}
.jio-creator__menu-item.is-active .jio-creator__menu-icon,
.jio-creator__menu-item:hover .jio-creator__menu-icon {
color: currentColor;
}
.jio-creator__menu-swatch {
width: 12px;
height: 12px;
flex: 0 0 12px;
margin: 0 2px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.jio-creator__menu-text {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 1px;
}
.jio-creator__menu-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jio-creator__menu-hint {
color: #9aa2a0;
font-size: 11px;
line-height: 1.3;
}
.jio-creator__menu-check {
flex: 0 0 auto;
color: var(--jc-accent);
font-size: 10px;
opacity: 0;
}
.jio-creator__menu-item.is-active .jio-creator__menu-check {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.jio-creator__menu {
animation: none;
}
}
@@ -728,16 +834,6 @@ body.jio-page-submit.has-static-nav {
font-size: 10px;
}
.jio-creator__format-field {
min-width: 36px;
height: 46px;
padding: 4px 4px;
i {
font-size: 13px;
}
}
.jio-creator__canvas {
width: calc(100% - 20px);
margin: 14px auto 0;
+69 -60
View File
@@ -2,8 +2,8 @@
* 正文块的 HTML 片段。
*
* 这些块都由服务端再次清洗(FrontSubmitSave 的 wp_kses_post),因此这里只负责
* 生成符合前台渲染习惯的结构:代码块沿用 enlighter 的既有高亮约定,引用沿用
* 主题的 .quote_q 结构,目录使用文章目录树识别标题的占位结构。
* 生成符合前台渲染习惯的结构:代码块沿用前台 enlighter 的 pre/code 约定,引用沿用
* 主题的 .quote_q 结构,隐藏内容沿用 .tinymce-hide 的短代码包裹结构。
*
* @package Jio
*/
@@ -13,64 +13,31 @@ 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>`;
}
/** 已经是块级结构的内容原样保留,纯行内内容补一个段落包裹。 */
const BLOCK_TAG = /<(?:p|div|h[1-6]|ul|ol|li|blockquote|pre|table|figure|section)\b/i;
const normalizeInner = (html: string): string => {
const trimmed = html.trim();
if (trimmed === '') {
return '<p></p>';
}
return BLOCK_TAG.test(trimmed) ? trimmed : `<p>${trimmed}</p>`;
};
/** 分割线。 */
export function dividerMarkup(): string {
return '<hr><p></p>';
}
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;
}
/** 代码块右上角角标:编辑器内由 React 渲染,这里给出标识供 CSS 定位。 */
export const CODE_LABEL_CLASS = 'jio-code-lang';
/**
* 代码块:语言写在 data 属性里,前台 enlighter 与编辑器角标共用。
* 代码块:与前台文章页一致,只落 pre > code。
*
* `data-jio-code` 标记这是编辑器创建的块,编辑器据此保证回车不跳出代码区、
* 并渲染可点击的语言角标。
* 前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,因此正文里不再写
* 语言 / 主题属性,也不需要编辑器内的语言角标。
*/
export function codeBlockMarkup(language = 'generic', code = ''): string {
const body = code !== '' ? escapeHtml(code) : escapeHtml(__('在此输入代码', 'jio'));
return (
`<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 function codeBlockMarkup(): string {
return '<pre><code></code></pre><p></p>';
}
/** 把代码块的语言写成编辑器里的内联标签(纯文本,保存前由编辑器移除)。 */
export type TableOptions = {
rows: number;
cols: number;
@@ -101,14 +68,6 @@ export function tableMarkup({ rows, cols, header, striped }: TableOptions): stri
return `<table class="${classes.join(' ')}">${body}</table><p></p>`;
}
/**
* 目录:文章页的目录树由前台 section-navs 依据 [data-nav] 生成,
* 短代码在服务端替换成目录卡片,编辑器里保留可识别的占位。
*/
export function tocMarkup(): string {
return '<p>[jio_toc]</p><p></p>';
}
export type MathOptions = { formula: string; display: boolean };
/** 公式的上标 / 下标排版(与 ContentBlocks::typeset_math 保持一致)。 */
@@ -143,12 +102,62 @@ export type QuoteColor = '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv';
/**
* 引用:沿用主题的 .quote_q 结构(.quote-mce 提供左侧引号标记),
* 颜色通过主题已有的 qe_wzk_* 类切换。
*
* 外层的 contenteditable=false 让整块保持为一个整体,内层 contenteditable=true
* 仍可直接编辑 —— 与前台投稿编辑器的引用块一致。
*/
export function quoteMarkup(content: string, color: QuoteColor): string {
export function quoteMarkup(innerHtml: 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>`;
return `<div class="${classes.join(' ')}" contenteditable="false"><div contenteditable="true">${normalizeInner(innerHtml)}</div></div><p></p>`;
}
export type HideKind = 'reply' | 'login' | 'vip' | 'payshow';
/**
* 隐藏内容的四档可见性。
*
* open / close 是真正的短代码文本,必须先写进 DOM 再被 TinyMCE 序列化,
* 服务端 do_shortcode 才能识别;label 只用于编辑器里的可读角标。
*/
export const HIDE_BLOCKS: Record<HideKind, { open: string; close: string; label: string }> = {
reply: { open: '[reply]', close: '[/reply]', label: __('评论后可查看', 'jio') },
login: { open: '[jio_hide mode="login"]', close: '[/jio_hide]', label: __('登录后可查看', 'jio') },
vip: { open: '[vip]', close: '[/vip]', label: __('会员可查看', 'jio') },
payshow: { open: '[payshow]', close: '[/payshow]', label: __('付费后可查看', 'jio') },
};
/**
* 隐藏内容块:短代码作为文本节点包住正文,服务端据此控制可见性。
*
* .hide-before 里的短代码文本通过 CSS 的 font-size:0 隐藏,只显示 data-jio-hide-label;
* .hide-after 与前台投稿编辑器一致地保持 display:none。两者都留在 DOM 里,保存时
* 不需要额外的序列化转换。
*/
export function hiddenContentMarkup(kind: HideKind, innerHtml: string): string {
const def = HIDE_BLOCKS[kind];
return (
`<div class="tinymce-hide" data-jio-hide-type="${kind}">` +
`<p class="hide-before" data-jio-hide-label="${escapeHtml(def.label)}">${escapeHtml(def.open)}</p>` +
`<div contenteditable="true">${normalizeInner(innerHtml)}</div>` +
`<p class="hide-after">${escapeHtml(def.close)}</p>` +
'</div><p></p>'
);
}
/** 把已有隐藏内容块换成另一种可见性(保留正文)。 */
export function retargetHiddenContent(node: HTMLElement, kind: HideKind): void {
const def = HIDE_BLOCKS[kind];
node.setAttribute('data-jio-hide-type', kind);
const before = node.querySelector(':scope > .hide-before');
const after = node.querySelector(':scope > .hide-after');
if (before) {
before.textContent = def.open;
before.setAttribute('data-jio-hide-label', def.label);
}
if (after) {
after.textContent = def.close;
}
}
+4 -6
View File
@@ -1,8 +1,9 @@
/**
* 编辑器内容 ↔ 保存格式的转换。
*
* 编辑器里把公式、注释、代码语言标签渲染成可读的样子(方便写作时看到最终效果),
* 但落库必须回到短代码/纯标记,服务端才能稳定渲染。这里在两个方向上都做一次转换。
* 编辑器里把公式、注释渲染成可读的样子(方便写作时看到最终效果),
* 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容与代码块本身就是
* 短代码 / 前台结构,不需要转换。
*
* @package Jio
*/
@@ -20,7 +21,7 @@ const decodeEntities = (value: string): string => {
* 保存前:把编辑器里的可视化块换回短代码 / 纯标记。
*/
export function serializeBlocks(html: string): string {
if (!html || (html.indexOf('data-jio-') === -1 && html.indexOf('jio-code-lang') === -1)) {
if (!html || html.indexOf('data-jio-') === -1) {
return html;
}
@@ -46,9 +47,6 @@ export function serializeBlocks(html: string): string {
node.replaceWith(document.createTextNode(`[jio_footnote]${note}[/jio_footnote]`));
});
// 代码块语言角标只是编辑期提示
container.querySelectorAll('.jio-code-lang').forEach((node) => node.remove());
return container.innerHTML;
}