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:
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* 代码块:与前台文章页一致。
|
||||
*
|
||||
|
||||
@@ -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">✕</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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user