正文超过 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>
273 lines
10 KiB
JavaScript
273 lines
10 KiB
JavaScript
'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);
|
||
});
|