Files
wordpress-theme-aksrt/tests/widget-system/submit-placeholder-behavior.test.cjs
T
kate522andClaude Opus 5 305896796c fix(submit): detect empty body without editor chrome and let body fill the frame
登录态实测(Edge + 真实 TinyMCE)复现了上一版没修好的两点:

1. 占位符仍不显示:editor-runtime 会把块级删除浮层插进 body,它带
   data-mce-bogus 但含「✕ 选中此块」文字,body.textContent 因此永远非空,
   data-jio-empty 一直没被打上。判断正文是否为空时先剔除 data-mce-bogus 节点。

2. 点空白落不到正文:body 高度只有 29px。`min-height:100%` 的父级 html
   没有确定高度,百分比退化成 auto。改为 html{height:100%} +
   body{min-height:100%},body 现在铺满 520px 的 iframe。

实测结果(登录态 https://localhost/submit/):
- 首次加载 data-jio-empty=1、::before 渲染「请输入正文」;
- 点击正文中部空白,光标落进 P 并可继续输入;
- 引用块内编辑后点空白,光标落到引用之后的段落;
- 清空内容后占位符恢复。

测试:新增「编辑器辅助节点不算正文」回归用例;900 → 901 全通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-09 21:16:45 +08:00

211 lines
7.8 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', () => {
// 真实页面上 editor-runtime 会把块删除浮层插进 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>',
{ 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('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);
});