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