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);