Files
wordpress-theme-aksrt/tests/widget-system/submit-placeholder-behavior.test.cjs
T
kate522 a061ec86ce 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.
2026-11-10 18:56:30 +08:00

273 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 投稿编辑器正文基础插件(front-submit-editor-basics.js)的行为测试。
*
* 用 jsdom 模拟 TinyMCE 暴露给插件的 API(getBody / dom / selection / getParam /
* 事件总线),直接跑真实的插件源码。
*
* 两个回归点:
* 1. 占位符曾以 `data-mce-bogus` 段落插进正文 —— TinyMCE 在 setContent 时会清掉它,
* 表现为「首次加载不显示,输入一个再删掉才出现」;现在改为 body 属性 + CSS ::before。
* 2. 光标在块级结构里时点击正文空白处无法落回正文;现在点击 body 会补一个可编辑
* 尾段并把光标放进去。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const { JSDOM } = require(path.join(root, 'assets/creator-studio/node_modules/jsdom'));
const pluginSource = fs.readFileSync(
path.join(root, 'assets/js/front-submit-editor-basics.js'),
'utf8'
);
/** 极简 TinyMCE 替身:只实现插件实际用到的部分。 */
function createEditor(html, settings = {}) {
const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`);
const doc = dom.window.document;
const listeners = {};
const state = { selectionNode: doc.body, caret: null };
const editor = {
settings,
id: 'jio-test-editor',
getParam(name, fallback) {
return Object.prototype.hasOwnProperty.call(settings, name) ? settings[name] : fallback;
},
getBody: () => doc.body,
getDoc: () => doc,
dom: {
create(tag, attrs, inner) {
const node = doc.createElement(tag);
Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, String(value)));
if (inner !== undefined) node.innerHTML = inner;
return node;
},
encode: (value) => String(value),
getParent: (node, selector) => (node && node.closest ? node.closest(selector) : null),
},
selection: {
getNode: () => state.selectionNode,
setCursorLocation(node, offset) {
state.caret = { node, offset };
},
},
on(names, handler) {
String(names)
.split(/\s+/)
.filter(Boolean)
.forEach((name) => {
(listeners[name] = listeners[name] || []).push(handler);
});
},
fire(name, payload = {}) {
(listeners[name] || []).forEach((handler) => handler(payload));
},
};
return { dom, doc, editor, state };
}
function loadPlugin() {
const registered = {};
const tinymce = {
PluginManager: {
add(name, factory) {
registered[name] = factory;
},
},
};
const fakeWindow = { tinymce, setTimeout: (fn) => fn() };
// 插件是 IIFE,只依赖传入的 window;用 Function 注入避免污染全局。
new Function('window', pluginSource)(fakeWindow);
return registered;
}
const PLUGIN = 'jio_editor_basics';
test('placeholder is flagged on first load of an empty document', () => {
const { doc, editor } = createEditor('<p><br data-mce-bogus="1"></p>', { placeholder: '请输入正文' });
const plugin = loadPlugin()[PLUGIN];
assert.equal(typeof plugin, 'function', `${PLUGIN} must register a plugin`);
plugin(editor);
editor.fire('init');
assert.equal(doc.body.getAttribute('data-jio-placeholder'), '请输入正文');
assert.equal(doc.body.getAttribute('data-jio-empty'), '1', 'empty body must be flagged for the CSS hint');
});
test('placeholder flag clears once the author types and returns when emptied', () => {
const { doc, editor } = createEditor('<p><br data-mce-bogus="1"></p>', { placeholder: '请输入正文' });
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
const paragraph = doc.body.querySelector('p');
paragraph.textContent = '真实内容';
editor.fire('input');
assert.equal(doc.body.hasAttribute('data-jio-empty'), false, 'flag must clear when content exists');
paragraph.textContent = '';
editor.fire('input');
assert.equal(doc.body.getAttribute('data-jio-empty'), '1', 'flag must come back when emptied');
});
test('media blocks count as content even without text', () => {
const { doc, editor } = createEditor('<p><img src="https://example.test/a.png"></p>', { placeholder: '请输入正文' });
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
assert.equal(doc.body.hasAttribute('data-jio-empty'), false);
});
test('placeholder never becomes a DOM node in the editable body', () => {
const { doc, editor } = createEditor('<p><br data-mce-bogus="1"></p>', { placeholder: '请输入正文' });
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
// 正文里只能有真实段落:占位符若作为节点存在,就会占住首行并挡住点击。
assert.equal(doc.body.children.length, 1);
assert.equal(doc.body.querySelector('p').className, '');
assert.equal(doc.body.textContent.trim(), '');
});
test('editor chrome injected into the body does not count as content', () => {
// TinyMCE 会往 body 里插带 data-mce-bogus 的工具节点,其中可能含文字。
// 若按 body.textContent 判断,空正文永远非空,占位符就永远不显示 ——
// 这正是「首次加载不显示」的真实原因。
const { doc, editor } = createEditor(
'<p><br data-mce-bogus="1"></p>' +
'<div data-mce-bogus="all">' +
'<button type="button"><span>✕</span><span>工具节点</span></button></div>',
{ placeholder: '请输入正文' }
);
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
assert.equal(doc.body.getAttribute('data-jio-empty'), '1', 'bogus chrome must not count as content');
});
test('editor iframe grows with the content instead of scrolling internally', () => {
const { doc, editor } = createEditor('<p>短正文</p>', { height: 520, placeholder: '请输入正文' });
// 极简 iframe 替身:插件只读写 style.height / overflow / scrolling。
const iframe = doc.createElement('iframe');
const container = doc.createElement('div');
container.appendChild(iframe);
editor.getContentAreaContainer = () => container;
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
const initial = iframe.style.height;
assert.equal(initial, '520px', 'short content keeps the declared writing area');
// 正文变长:scrollHeight 变大,iframe 必须跟着长。
Object.defineProperty(doc.body, 'scrollHeight', { configurable: true, get: () => 2400 });
Object.defineProperty(doc.body, 'offsetHeight', { configurable: true, get: () => 2400 });
editor.fire('input');
assert.equal(iframe.style.height, '2400px', 'iframe height must follow the content');
assert.equal(iframe.style.overflow, 'hidden');
assert.equal(iframe.getAttribute('scrolling'), 'no');
});
test('height measurement ignores the body min-height fill', () => {
// body 的 min-height 让空正文铺满视口;如果测量时也带上它,
// 就会得到「撑高 → 再撑高」的反馈循环(实测会涨到两万多 px)。
const { doc, editor } = createEditor('<p>正文</p>', { height: 520 });
const iframe = doc.createElement('iframe');
const container = doc.createElement('div');
container.appendChild(iframe);
editor.getContentAreaContainer = () => container;
// 模拟「body 被 min-height 撑到 5000」但真实内容只有 1800。
let minHeightApplied = false;
Object.defineProperty(doc.body, 'scrollHeight', {
configurable: true,
get: () => (minHeightApplied ? 5000 : 1800),
});
Object.defineProperty(doc.body, 'offsetHeight', {
configurable: true,
get: () => (minHeightApplied ? 5000 : 1800),
});
// 插件把 min-height 归零时视为摘掉撑满。
Object.defineProperty(doc.body.style, 'minHeight', {
configurable: true,
get: () => (minHeightApplied ? '100vh' : ''),
set: (value) => {
minHeightApplied = value !== '0';
},
});
minHeightApplied = true;
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
assert.equal(iframe.style.height, '1800px', 'measure the content, not the viewport fill');
});
test('no placeholder configured leaves the body untouched', () => {
const { doc, editor } = createEditor('<p><br data-mce-bogus="1"></p>', {});
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
assert.equal(doc.body.hasAttribute('data-jio-placeholder'), false);
assert.equal(doc.body.hasAttribute('data-jio-empty'), false);
});
test('clicking blank space after a structural block appends an editable paragraph', () => {
// 正文以代码块结尾:光标在块内时点击空白处,必须有可编辑段落承载光标。
const { doc, editor, state } = createEditor('<p>正文</p><pre><code>code</code></pre>');
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
state.selectionNode = doc.querySelector('pre');
editor.fire('click', { target: doc.body });
const last = doc.body.lastElementChild;
assert.equal(last.tagName, 'P', 'a paragraph must be appended after the structural block');
assert.equal(state.caret && state.caret.node, last, 'caret must move into that paragraph');
});
test('clicking blank space while the caret is already in text does not move it', () => {
const { doc, editor, state } = createEditor('<p>正文</p><p>第二段</p>');
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
state.selectionNode = doc.querySelectorAll('p')[1];
editor.fire('click', { target: doc.body });
assert.equal(state.caret, null, 'browser-placed caret must be left alone');
assert.equal(doc.body.children.length, 2, 'no extra paragraph is added');
});
test('clicking a nested editable block does not append a trailing paragraph', () => {
const { doc, editor, state } = createEditor(
'<div class="quote_q"><div contenteditable="true"><p>引用内容</p></div></div>'
);
const plugin = loadPlugin()[PLUGIN];
plugin(editor);
editor.fire('init');
state.selectionNode = doc.querySelector('.quote_q p');
editor.fire('click', { target: doc.querySelector('.quote_q p') });
assert.equal(state.caret, null);
assert.equal(doc.body.children.length, 1);
});