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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user