fix(submit): show body placeholder on first load and restore caret on blank clicks
两个前台投稿编辑器的缺陷,根因都在占位符的实现方式: 1. 首次加载不显示占位符,输入一个再删掉才出现。 旧实现往正文插了一个 data-mce-bogus 占位段落,而 TinyMCE 在 setContent(studio 初始化时必调)与清理 bogus 元素时会把它移除。 2. 光标在块级结构里时,点击正文空白处无法落回正文。 占位段落占着首行、且 pointer-events:none,点击落不到可编辑元素上; 正文以表格 / 代码块这类结构块结尾时也没有可承载光标的段落。 改为 front-submit-editor-basics.js(替换 front-submit-placeholder.js): - 占位符改成 body 属性 + content CSS 的 ::before,正文 DOM 不含占位节点; - 点击空白处(事件目标是 body)且光标不在普通文本里时,补一个可编辑尾段 并把光标放进去; - 两份 content CSS 让 html/body 撑满编辑区,studio 的 syncEditorHeight 以编辑器声明高度为下限,空正文时 iframe 不会缩成一行。 新增 tests/widget-system/submit-placeholder-behavior.test.cjs(jsdom 跑真实插件)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c2733017fd
commit
5afa7ab9df
10 files changed
+428
-172
No files matched your search
@@ -378,35 +378,60 @@ test('quote, hidden content, and block math render like the live article', () =>
|
||||
});
|
||||
|
||||
test('submit editor renders the configured body placeholder', () => {
|
||||
const plugin = read('assets/js/front-submit-placeholder.js');
|
||||
const plugin = read('assets/js/front-submit-editor-basics.js');
|
||||
const classic = read('templates/parts/submit/editor-main.php');
|
||||
const studio = read('src/Core/CreatorStudio.php');
|
||||
const styles = read('assets/css/frontend/pages/submit-editor.css');
|
||||
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
|
||||
const runtime = read('assets/creator-studio/src/editor-runtime.ts');
|
||||
|
||||
// WordPress 打包的 TinyMCE 4.9 没有 placeholder 插件,主题自己实现。
|
||||
assert.match(plugin, /PluginManager\.add\('jio_placeholder'/);
|
||||
assert.match(plugin, /PluginManager\.add\('jio_editor_basics'/);
|
||||
assert.match(plugin, /getParam\('placeholder'/);
|
||||
assert.match(plugin, /data-mce-bogus/);
|
||||
// 占位段落不能写进文章内容。
|
||||
assert.match(plugin, /addNodeFilter\('p', strip\)/);
|
||||
// 占位符不插 DOM 节点:插入的段落会被 setContent 清掉(首次加载不显示),
|
||||
// 也会占住首行挡住点击。改为 body 属性 + CSS ::before。
|
||||
assert.match(plugin, /body\.setAttribute\(EMPTY_ATTR, '1'\)/);
|
||||
// 占位符不能作为 bogus 段落插进正文(补空段落用的 <br data-mce-bogus> 是另一回事)。
|
||||
assert.doesNotMatch(plugin, /'data-mce-bogus':\s*'all'/);
|
||||
assert.doesNotMatch(plugin, /addNodeFilter/);
|
||||
// 图片 / 视频 / 表格即使没有文字也算有内容。
|
||||
assert.match(plugin, /MEDIA = 'img,iframe,table,pre,hr,video,audio,figure/);
|
||||
|
||||
// 两条投稿路径都要加载插件,否则占位符在其中一个页面消失。
|
||||
// 注意:WordPress 会丢弃名字已出现在 plugins 列表里的外部插件,
|
||||
// 所以 jio_placeholder 只能走 external_plugins。
|
||||
// 所以 jio_editor_basics 只能走 external_plugins。
|
||||
assert.match(classic, /\$plugins\s*=\s*'[^']*'/);
|
||||
assert.doesNotMatch(classic, /\$plugins\s*=\s*'[^']*jio_placeholder/);
|
||||
assert.match(classic, /\$external\['jio_placeholder'\]\s*=\s*\$placeholder_plugin_url/);
|
||||
assert.doesNotMatch(classic, /\$plugins\s*=\s*'[^']*jio_editor_basics/);
|
||||
assert.match(classic, /\$external\['jio_editor_basics'\]\s*=\s*\$editor_basics_plugin_url/);
|
||||
assert.match(classic, /'placeholder'\s*=>\s*\(string\) \(\$settings\['content_placeholder'\]/);
|
||||
assert.match(studio, /\['placeholderPlugin'\]\s*=\s*\$this->placeholder_plugin_url\(\)/);
|
||||
assert.doesNotMatch(appSource, /plugins: '[^']*jio_placeholder/);
|
||||
assert.match(appSource, /external_plugins: bootstrap\.creator\?\.placeholderPlugin/);
|
||||
assert.match(studio, /\['editorBasicsPlugin'\]\s*=\s*\$this->editor_basics_plugin_url\(\)/);
|
||||
assert.doesNotMatch(appSource, /plugins: '[^']*jio_editor_basics/);
|
||||
assert.match(appSource, /external_plugins: bootstrap\.creator\?\.editorBasicsPlugin/);
|
||||
|
||||
// 占位符样式必须写进 iframe 的 content CSS,页面样式影响不到编辑器内部。
|
||||
assert.match(styles, /\.jio-editor-placeholder\s*\{/);
|
||||
assert.match(contentStyle, /\.jio-editor-placeholder\s*\{/);
|
||||
assert.match(styles, /body\[data-jio-empty="1"\]::before\s*\{/);
|
||||
assert.match(contentStyle, /body\[data-jio-empty="1"\]::before\s*\{/);
|
||||
// 两份 content CSS 都要让 body 撑满,否则正文下方的空白不属于 iframe。
|
||||
assert.match(styles, /html,\s*\n\s*body\.mce-content-body\s*\{[^}]*min-height:\s*100%/);
|
||||
assert.match(contentStyle, /html,\s*\n\s*body\s*\{[^}]*min-height:\s*100%/);
|
||||
|
||||
// 空正文时 iframe 不能缩到只剩一行:高度下限取编辑器声明的高度。
|
||||
assert.match(runtime, /getParam\?\.\('height', 0\)/);
|
||||
});
|
||||
|
||||
test('clicking blank space returns the caret to the body', () => {
|
||||
const plugin = read('assets/js/front-submit-editor-basics.js');
|
||||
const styles = read('assets/css/frontend/pages/submit-editor.css');
|
||||
|
||||
// 光标停在结构块(表格 / 代码块 / 引用…)里时,点击正文空白处要补一个尾段。
|
||||
assert.match(plugin, /var STRUCTURAL = 'table,pre,hr,figure,\.quote_q,\.tinymce-hide/);
|
||||
assert.match(plugin, /function caretHome\(\)/);
|
||||
assert.match(plugin, /editor\.selection\.setCursorLocation\(home/);
|
||||
assert.match(plugin, /editor\.on\('click', restoreCaret\)/);
|
||||
// 光标已经在普通段落里时不能抢走浏览器的落点。
|
||||
assert.match(plugin, /if \(insideText\) \{\s*return;/);
|
||||
// 占位提示不能吃掉点击。
|
||||
assert.match(styles, /body\[data-jio-empty="1"\]::before\s*\{[^}]*pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
test('creator studio build artifacts and contracts exist', () => {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
'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('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);
|
||||
});
|
||||
Reference in new issue
Block a user