refactor(submit): drop the hover block-delete overlay

Hovering a quote, hidden block, code block or table showed a ✕ button in the
block's top-right corner. It read as a stray close button and is not wanted.

Removed
- installBlockActions() and the two-step select/delete flow it drove
- the block-actions markup, positioning, selected-state CSS in the iframe
  content style
- BLOCK_SELECTOR, which only the overlay used

Deleting blocks still works: removeAdjacentBlock() keeps Backspace / Delete
next to a structural block (table, pre, hr, quote, hidden block) working, and
that is covered by both code and tests.
This commit is contained in:
kate522 committed 2026-11-10 18:56:30 +08:00
1 parent 49f1212e33
commit a061ec86ce
6 files changed
+25 -288

No files matched your search

@@ -2,8 +2,7 @@
* TinyMCE iframe 内的正文样式。
*
* 编辑器正文跑在 iframe 里,页面级 SCSS 影响不到它,所以这里显式给出:
* 正文排版、主题引用 .quote_q、隐藏内容 .tinymce-hide、表格、公式、
* 块级删除控件与块选中态。
* 正文排版、主题引用 .quote_q、隐藏内容 .tinymce-hide、表格、公式。
*
* @package Jio
*/
@@ -53,66 +52,6 @@ h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.65em; line-height: 1.4; }
img, iframe { max-width: 100%; }
img { height: auto; }
/*
* 块级删除控件。它由 editor-runtime 插进 iframe 的 body,样式必须写在这里:
* 父页面样式表管不到 iframe 内部,缺这条规则时按钮会退化成正文顶部的常显小方块。
* 正文是流式布局且 iframe 高度随内容增长,所以按整篇正文定位,而不是相对某个块。
*/
#jio-block-actions {
position: absolute;
z-index: 5;
top: 6px;
left: 6px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
#jio-block-actions.is-visible {
opacity: 1;
pointer-events: auto;
}
#jio-block-actions .jio-creator__block-action {
display: inline-flex;
height: 26px;
align-items: center;
gap: 5px;
padding: 0 9px;
border: 1px solid #e3e7e5;
border-radius: 6px;
background: #fff;
color: #7d8683;
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: #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;
}
/* 第一次点击选中的块:虚线高亮,明确「还没删」。 */
.jio-creator__block-selected {
outline: 2px dashed rgba(180, 87, 63, 0.55);
outline-offset: 3px;
border-radius: 4px;
}
/*
* 代码块:与前台文章页一致。
*
+2 -188
View File
@@ -1,14 +1,11 @@
/**
* TinyMCE 编辑器增强:块级两步删除、自适应高度。
* TinyMCE 编辑器增强:块级删除键、自适应高度。
*
* 这些行为都作用在编辑器 iframe 内部,与 React 状态无关,因此独立成模块。
*
* @package Jio
*/
/** 可整体删除的块级元素选择器。 */
const BLOCK_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,p';
/** 结构块(不能从内部被拆开的块)。 */
const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,.jio-math--block';
@@ -123,7 +120,7 @@ export function syncEditorHeight(editor: any): void {
* 变高。这里在 iframe 内监听资源加载与尺寸变化,加载完成后重新同步一次,
* 否则图片下方的段落会被 iframe 的固定高度裁掉。
*
* 只在 body 上挂一次(installBlockActions 同一条 init 路径),避免重复监听。
* 只在 body 上挂一次(与高度同步共用同一条 init 路径),避免重复监听。
*/
export function installHeightWatchers(editor: any): void {
const doc = editor?.getDoc?.();
@@ -150,186 +147,3 @@ export function installHeightWatchers(editor: any): void {
new (window as any).ResizeObserver(() => syncEditorHeight(editor)).observe(doc.body);
}
}
/** 块选中状态写在块自身的 class 上,跨 iframe 重排也能保留。 */
const SELECTED_CLASS = 'jio-creator__block-selected';
/**
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
*
* 与 WPS 一致的两步删除:第一次点击只是选中该块并高亮,第二次点击才真正删除。
* 选中态下按钮改为醒目的「再次点击删除」,且鼠标移开不会消失,避免误删。
*
* 浮层挂在 iframe 的 `documentElement` 而不是 `body` 里:`data-mce-bogus` 只让
* TinyMCE 跳过序列化,选区计算仍会把它算进 body 的子节点;body 里多一个非常规
* 子节点时,插入块(例如「隐藏」)会在 mceInsertContent 里把整篇正文清空。挂在
* html 上既不影响正文,也能用同一个坐标系定位。
*
* 它的样式写在 editor-content-style.ts(父页面样式表管不到 iframe 内部)。
*/
export function installBlockActions(editor: any): void {
const doc = editor.getDoc();
if (!doc || doc.getElementById('jio-block-actions')) {
return;
}
const bar = doc.createElement('div');
bar.id = 'jio-block-actions';
bar.className = 'jio-creator__block-actions';
// "all" 让 TinyMCE 连同子节点一起跳过。
bar.setAttribute('data-mce-bogus', 'all');
bar.innerHTML =
'<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.documentElement.appendChild(bar);
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 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 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) {
bar.classList.remove('is-visible');
return;
}
// 空段落没有删除价值,交给退格键处理。
if (block.tagName === 'P' && (block.textContent || '').trim() === '' && !block.querySelector('img,iframe,table,pre')) {
bar.classList.remove('is-visible');
return;
}
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) {
return;
}
// 选中块时保持浮层贴在选中块上,不受悬停目标影响。
if (selected) {
return;
}
show(block);
});
doc.addEventListener('mouseleave', () => {
hovered = null;
if (selected) {
place(selected);
return;
}
bar.classList.remove('is-visible');
});
bar.addEventListener('mousedown', (event: MouseEvent) => event.preventDefault());
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();
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(victim);
});
if (editor.getBody().children.length === 0) {
editor.setContent('<p></p>');
}
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');
}
});
}
-2
View File
@@ -38,7 +38,6 @@ import {
import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog';
import { type Draft, type Limits, initialDraft } from './draft';
import {
installBlockActions,
installHeightWatchers,
removeAdjacentBlock,
syncEditorHeight,
@@ -445,7 +444,6 @@ function Studio(): JSX.Element {
editor.setContent(hydrateBlocks(editor.getContent()));
}
syncEditorHeight(editor);
installBlockActions(editor);
installHeightWatchers(editor);
updateHistoryAvailability(editor);
updateFormatValue(editor);
+2 -3
View File
@@ -48,9 +48,8 @@
/**
* 正文自身的副本:剔除 TinyMCE 的辅助节点。
*
* 编辑器会把块级删除浮层(#jio-block-actions)之类带文字的工具节点插进
* body,它们都带 data-mce-bogus。直接读 body.textContent 会把「选中此块」
* 当成正文,导致空正文永远判成非空、占位符不显示。
* 编辑器会把带文字的工具节点插进 body,它们都带 data-mce-bogus。直接读
* body.textContent 会把工具文案当成正文,导致空正文永远判成非空、占位符不显示。
*/
function contentClone(body) {
var clone = body.cloneNode(true);
@@ -312,38 +312,25 @@ test('top bar is masthead + centered toolbar, with no brand text in the toolbar
assert.doesNotMatch(rest, /'home'\s*=>\s*esc_url_raw\(\$home\)/);
});
test('block delete overlay lives outside the editable body and stays hidden', () => {
// 块右上角的悬停删除浮层已移除:删除统一走键盘(退格 / Delete)与工具栏。
// 浮层留在这里会被误当成「不需要的关闭按钮」,也曾在 body 里破坏 mceInsertContent。
test('hover block-delete overlay is gone', () => {
const runtime = read('assets/creator-studio/src/editor-runtime.ts');
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
const pageStyles = read('assets/creator-studio/src/styles/_block-tools.scss');
const app = read('assets/creator-studio/src/index.tsx');
// 浮层挂在 iframe 的 documentElement 上,不能进 body:data-mce-bogus 只跳过
// 序列化,body 里多一个非常规子节点会让 mceInsertContent 把整篇正文清空
// (表现为「第一次点隐藏块没有插进正文」)。
assert.match(runtime, /doc\.documentElement\.appendChild\(bar\)/);
assert.doesNotMatch(runtime, /doc\.body\.appendChild\(bar\)/);
assert.match(runtime, /bar\.id = 'jio-block-actions'/);
// iframe 是独立文档:页面级样式表管不到它,隐藏规则必须写在 content_style。
assert.match(contentStyle, /#jio-block-actions\s*\{[^}]*position:\s*absolute/);
assert.match(contentStyle, /#jio-block-actions\s*\{[^}]*opacity:\s*0/);
assert.match(contentStyle, /#jio-block-actions\.is-visible\s*\{[^}]*opacity:\s*1/);
assert.match(contentStyle, /body\s*\{[^}]*position:\s*relative/);
// 页面级 SCSS 不再重复定义 iframe 内部的选择器(否则只是死样式)。
assert.doesNotMatch(pageStyles, /\.jio-creator__block-actions\s*\{/);
// data-mce-bogus 让 TinyMCE 序列化、字数和选区都跳过这个浮层。
assert.match(runtime, /setAttribute\('data-mce-bogus', 'all'\)/);
assert.doesNotMatch(runtime, /jio-block-actions/);
assert.doesNotMatch(runtime, /jio-creator__block-actions/);
assert.doesNotMatch(runtime, /installBlockActions/);
assert.doesNotMatch(runtime, /BLOCK_SELECTOR/);
assert.doesNotMatch(contentStyle, /jio-block-actions/);
assert.doesNotMatch(contentStyle, /jio-creator__block-selected/);
assert.doesNotMatch(app, /installBlockActions/);
// 两步删除:第一次点击只选中,第二次才真删;不再有关闭 / 叉号语义。
const firstClick = runtime.indexOf('if (selected !== block) {');
const secondClick = runtime.indexOf('// 第二次点击:真正删除。');
assert.ok(firstClick !== -1 && secondClick !== -1, 'two-step delete branches both exist');
assert.ok(firstClick < secondClick, 'first click only selects');
assert.ok(
runtime.indexOf('block.classList.add(SELECTED_CLASS)', firstClick) < secondClick,
'selection class is applied on the first click'
);
assert.match(contentStyle, /\.jio-creator__block-selected\s*\{/);
assert.match(runtime, /再次点击删除此块/);
// 键盘删除仍然保留:空段落旁的结构块要能被退格删掉。
assert.match(runtime, /export function removeAdjacentBlock/);
assert.match(runtime, /STRUCTURAL_SELECTOR/);
assert.match(app, /removeAdjacentBlock\(editor, event\)/);
});
// 工具栏下拉:菜单必须 portal 到 body,否则会被横向滚动的工具条裁剪。
@@ -140,13 +140,13 @@ test('placeholder never becomes a DOM node in the editable body', () => {
});
test('editor chrome injected into the body does not count as content', () => {
// 真实页面上 editor-runtime 会把块删除浮层插进 body,它带 data-mce-bogus
// 但含文字(「✕ 选中此块」)。若按 body.textContent 判断,空正文永远非空,
// 占位符就永远不显示 —— 这正是「首次加载不显示」的真实原因。
// TinyMCE 会往 body 里插带 data-mce-bogus 的工具节点,其中可能含文字。
// 若按 body.textContent 判断,空正文永远非空,占位符就永远不显示 ——
// 这正是「首次加载不显示」的真实原因。
const { doc, editor } = createEditor(
'<p><br data-mce-bogus="1"></p>' +
'<div id="jio-block-actions" data-mce-bogus="all">' +
'<button type="button"><span>✕</span><span>选中此块</span></button></div>',
'<div data-mce-bogus="all">' +
'<button type="button"><span>✕</span><span>工具节点</span></button></div>',
{ placeholder: '请输入正文' }
);
const plugin = loadPlugin()[PLUGIN];