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

+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;
}