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

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