Files
wordpress-theme-aksrt/tests/widget-system/submit-placeholder-behavior.test.cjs
T
kate522andClaude Opus 5 4f9347b6aa fix(submit): let the editor iframe grow with the body instead of scrolling internally
正文超过 iframe 高度时会在编辑器内部出现第二根滚动条,正文应该随页面一起变长。
登录态实测确认了滚动来源与两个副作用:

1. 经典投稿表单完全没做高度同步(iframe 固定 504px,正文 1824px → 内滚动)。
   在共用基础插件里补上 syncHeight():测量正文高度并同步 iframe,
   下限取编辑器声明高度;图片加载后重新同步。工作台自带 syncEditorHeight()
   负责块浮层定位,用 __jioStudioOwnsEditorHeight 让插件跳过,避免双写。

2. 直接量 scrollHeight 会把 body 的 min-height 撑满算进内容高度,形成
   「撑高 → 再撑高」的反馈循环(实测涨到 22616px)。测量时临时把
   min-height 归零、同帧恢复。

3. body 的 min-height 基准从 100% 改成 100vh:iframe 随正文变高,
   百分比会跟着循环;同时把 WordPress content CSS 的 body 外边距改内边距,
   外边距不计入高度,会让 html 比 iframe 高一截、长正文时又冒出内滚动。

登录态实测(studio 与经典两条路径):
- iframe 2152px / 1874px,与正文一致,innerHasScrollbar=false;
- 空正文占位符正常、点击空白落光标正常;
- 只有页面级滚动。

测试:新增 iframe 随正文增长、测量忽略 min-height 两个回归用例;903 全通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-09 21:37:14 +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', () => {
// 真实页面上 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('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);
});