diff --git a/assets/creator-studio/src/editor-content-style.ts b/assets/creator-studio/src/editor-content-style.ts
index 15f20bea..18f73dbc 100644
--- a/assets/creator-studio/src/editor-content-style.ts
+++ b/assets/creator-studio/src/editor-content-style.ts
@@ -22,14 +22,33 @@ p { margin: 0 0 1.1em; }
/*
* 正文占位符:TinyMCE 4.9 没有内置 placeholder 插件,由主题插件
- * (front-submit-placeholder.js)在正文为空时插入这个不可编辑的段落。
+ * (front-submit-editor-basics.js)在正文为空时给 body 打 data-jio-empty,
+ * 这里用 ::before 渲染提示。不插 DOM 节点,避免被 setContent 清掉、
+ * 也避免占住首行挡住点击。
*/
-.jio-editor-placeholder {
+body[data-jio-empty="1"]::before {
+ position: absolute;
+ top: 0;
+ left: 0;
color: #b9bfbc;
- cursor: text;
+ content: attr(data-jio-placeholder);
pointer-events: none;
user-select: none;
}
+
+/*
+ * body 撑满编辑区:内容很短时点击正文下方的空白区域也应落光标。
+ * iframe 高度由 syncEditorHeight() 跟随内容,这里保证 body 至少铺满。
+ */
+html,
+body {
+ min-height: 100%;
+}
+
+body {
+ box-sizing: border-box;
+}
+
h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.65em; line-height: 1.4; }
img, iframe { max-width: 100%; }
img { height: auto; }
diff --git a/assets/creator-studio/src/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts
index c2ea9a31..25a5780c 100644
--- a/assets/creator-studio/src/editor-runtime.ts
+++ b/assets/creator-studio/src/editor-runtime.ts
@@ -87,6 +87,10 @@ export function removeAdjacentBlock(editor: any, event: any): void {
*
* TinyMCE 的 iframe 不会自动撑高;这里测量内容高度并同步容器,
* 页面整体滚动由外层工作台负责。
+ *
+ * 下限取编辑器初始化时声明的高度(studio 为 520),这样正文很短时 iframe
+ * 也保持一个完整写作区,空白处由 content CSS 的 body min-height 撑满,
+ * 点击正文下方仍能落光标。
*/
export function syncEditorHeight(editor: any): void {
if (!editor || editor.removed) {
@@ -97,8 +101,9 @@ export function syncEditorHeight(editor: any): void {
if (!doc || !iframe) {
return;
}
+ const floor = Math.max(320, Number(editor.getParam?.('height', 0)) || 0);
const height = Math.max(
- 320,
+ floor,
doc.documentElement.scrollHeight,
doc.body ? doc.body.scrollHeight : 0
);
diff --git a/assets/creator-studio/src/index.tsx b/assets/creator-studio/src/index.tsx
index 815da637..4a2ae795 100644
--- a/assets/creator-studio/src/index.tsx
+++ b/assets/creator-studio/src/index.tsx
@@ -376,9 +376,9 @@ function Studio(): JSX.Element {
toolbar2: '',
toolbar3: '',
plugins: 'paste,link,lists,wordpress,wplink',
- // WordPress 打包的 TinyMCE 没有 placeholder 插件,占位符由主题插件实现。
- external_plugins: bootstrap.creator?.placeholderPlugin
- ? { jio_placeholder: bootstrap.creator.placeholderPlugin }
+ // 占位符与空白处落光标由主题插件实现(TinyMCE 4.9 没有 placeholder 插件)。
+ external_plugins: bootstrap.creator?.editorBasicsPlugin
+ ? { jio_editor_basics: bootstrap.creator.editorBasicsPlugin }
: undefined,
wpautop: true,
body_class: 'jio-creator__tinymce',
diff --git a/assets/css/frontend/pages/submit-editor.css b/assets/css/frontend/pages/submit-editor.css
index f0eee69c..d0e80090 100644
--- a/assets/css/frontend/pages/submit-editor.css
+++ b/assets/css/frontend/pages/submit-editor.css
@@ -204,11 +204,36 @@ pre code {
vertical-align: super;
}
-/* 正文占位符(TinyMCE 4.9 没有内置 placeholder 插件,由主题插件插入) */
-.mce-content-body .jio-editor-placeholder,
-.jio-editor-placeholder {
+/*
+ * 正文占位符。
+ *
+ * TinyMCE 4.9 没有内置 placeholder 插件,主题插件 front-submit-editor-basics.js
+ * 在正文为空时给 body 打上 data-jio-empty="1",这里用 ::before 渲染提示。
+ * 刻意不插 DOM 节点:插入的段落会被 setContent 清掉(首次加载不显示),
+ * 也会占住首行、挡住点击空白处落光标。
+ *
+ * 绝对定位覆盖在空段落上,与原生 placeholder 的观感一致。
+ */
+body[data-jio-empty="1"]::before {
+ position: absolute;
+ top: 0;
+ left: 0;
color: #b9bfbc;
- cursor: text;
+ content: attr(data-jio-placeholder);
pointer-events: none;
user-select: none;
}
+
+/*
+ * body 撑满编辑区:内容很短时点击正文下方的空白区域也应落光标,
+ * 否则只有文字所在的那一行能点。
+ */
+html,
+body.mce-content-body {
+ min-height: 100%;
+}
+
+body.mce-content-body {
+ position: relative;
+ box-sizing: border-box;
+}
diff --git a/assets/js/front-submit-editor-basics.js b/assets/js/front-submit-editor-basics.js
new file mode 100644
index 00000000..04882782
--- /dev/null
+++ b/assets/js/front-submit-editor-basics.js
@@ -0,0 +1,123 @@
+/**
+ * 投稿编辑器的正文基础行为(经典投稿表单与创作者工作台共用)。
+ *
+ * 1) 正文占位符
+ * WordPress 打包的 TinyMCE 4.9 不含官方 `placeholder` 插件,`tinymce.init`
+ * 里的 `placeholder` 配置会被静默忽略。这里改为在 body 上打属性、由 content
+ * CSS 的 ::before 渲染:
+ * - data-jio-placeholder="<提示文字>":始终存在,供 CSS 取文案;
+ * - data-jio-empty="1":仅正文为空时存在,控制是否显示。
+ * 刻意不插占位节点:插入的段落会被 TinyMCE 在 setContent / 清理 bogus 元素时
+ * 移除(表现为「首次加载不显示,输入再删掉才出现」),也会占住首行。
+ *
+ * 2) 空白区域落光标
+ * 光标停在引用 / 隐藏内容这类嵌套可编辑块里时,点击正文下方的空白处不一定
+ * 能把光标移回正文;正文以表格、代码块这类结构块结尾时更是无处可落。这里
+ * 在点击空白处时补一个可编辑尾段并把光标放进去。
+ *
+ * 视觉样式写在 content CSS(submit-editor.css / editor-content-style.ts)——
+ * 编辑器正文在 iframe 里,页面样式管不到。
+ *
+ * @package Jio
+ */
+(function (window) {
+ 'use strict';
+
+ if (!window.tinymce || !window.tinymce.PluginManager) {
+ return;
+ }
+
+ var TEXT_ATTR = 'data-jio-placeholder';
+ var EMPTY_ATTR = 'data-jio-empty';
+ var MEDIA = 'img,iframe,table,pre,hr,video,audio,figure,.jio-math--block';
+ /** 不能直接承载光标的块:结尾是它们时必须补一个段落。 */
+ var STRUCTURAL = 'table,pre,hr,figure,.quote_q,.tinymce-hide,.jio-math--block,.jio-file-link';
+
+ window.tinymce.PluginManager.add('jio_editor_basics', function (editor) {
+ var placeholder = String(editor.getParam('placeholder', '') || '').trim();
+
+ function root() {
+ return editor.getBody();
+ }
+
+ function isEmpty() {
+ var body = root();
+ if (!body) {
+ return false;
+ }
+ // 图片 / 视频 / 表格这类块即使没有文字也算有内容。
+ if (body.querySelector(MEDIA)) {
+ return false;
+ }
+ return String(body.textContent || '').replace(/[\s ]+/g, '') === '';
+ }
+
+ /** 空正文标记交给 CSS 渲染提示文字。 */
+ function syncPlaceholder() {
+ var body = root();
+ if (!body || !placeholder) {
+ return;
+ }
+ body.setAttribute(TEXT_ATTR, placeholder);
+ if (isEmpty()) {
+ body.setAttribute(EMPTY_ATTR, '1');
+ } else {
+ body.removeAttribute(EMPTY_ATTR);
+ }
+ }
+
+ function isStructural(node) {
+ return !!node && node.nodeType === 1 && !!node.matches && node.matches(STRUCTURAL);
+ }
+
+ /**
+ * 光标可落的位置:正文最后一个块。
+ *
+ * 结尾是结构块(表格 / 代码块 / 引用…)时补一个空段落,否则光标无处可放。
+ */
+ function caretHome() {
+ var body = root();
+ if (!body) {
+ return null;
+ }
+ var last = body.lastElementChild;
+ if (isStructural(last)) {
+ last = editor.dom.create('p', {}, '
');
+ body.appendChild(last);
+ }
+ return last;
+ }
+
+ /**
+ * 点击正文空白处(事件目标就是 body)时把光标移回正文末尾。
+ *
+ * 光标还在普通段落里时不插手:浏览器已经把它放在了正确位置。
+ */
+ function restoreCaret(event) {
+ var body = root();
+ if (!body || event.target !== body) {
+ return;
+ }
+ var node = editor.selection.getNode();
+ var insideText = node && node !== body && node.nodeType === 1
+ && !isStructural(node) && !editor.dom.getParent(node, STRUCTURAL, body);
+ if (insideText) {
+ return;
+ }
+ var home = caretHome();
+ if (!home) {
+ return;
+ }
+ editor.selection.setCursorLocation(home, home.childNodes.length);
+ }
+
+ // PreInit 时 body 可能已就绪:先打上属性,避免首帧闪一下空白再出现提示。
+ editor.on('PreInit init', syncPlaceholder);
+ editor.on('SetContent input keyup change Undo Redo NodeChange', function () {
+ // SetContent 之后 DOM 才稳定,放到下一帧再判断。
+ window.setTimeout(syncPlaceholder, 0);
+ });
+
+ editor.on('click', restoreCaret);
+ });
+})(window);
diff --git a/assets/js/front-submit-placeholder.js b/assets/js/front-submit-placeholder.js
deleted file mode 100644
index acbaff9f..00000000
--- a/assets/js/front-submit-placeholder.js
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * TinyMCE 正文占位符插件。
- *
- * WordPress 打包的 TinyMCE 4.9 不含官方 `placeholder` 插件,`tinymce.init` 里的
- * `placeholder` 配置会被静默忽略——投稿页的「投稿内容占位符」因此从来没有显示过。
- * 这里用官方插件同样的机制补上:正文为空时插入一个不可编辑的占位段落,
- * 有内容时移除。
- *
- * 占位段落带 `data-mce-bogus="all"`,TinyMCE 不参与序列化、字数和选区计算;
- * 视觉样式写在 content CSS(submit-editor.css / editor-content-style.ts)的
- * `.jio-editor-placeholder` 上——编辑器正文在 iframe 里,页面样式管不到。
- *
- * @package Jio
- */
-(function (window) {
- 'use strict';
-
- if (!window.tinymce || !window.tinymce.PluginManager) {
- return;
- }
-
- var ATTR = 'data-jio-placeholder';
- var MEDIA = 'img,iframe,table,pre,hr,video,audio,figure,.jio-math--block';
-
- window.tinymce.PluginManager.add('jio_placeholder', function (editor) {
- var text = String(editor.getParam('placeholder', '') || '').trim();
-
- if (!text) {
- return;
- }
-
- function body() {
- return editor.getBody();
- }
-
- function nodes() {
- var root = body();
- return root ? root.querySelectorAll('[' + ATTR + ']') : [];
- }
-
- /** 正文字数:跳过占位段落自身的文字。 */
- function contentText() {
- var root = body();
- if (!root) {
- return '';
- }
- var clone = root.cloneNode(true);
- Array.prototype.forEach.call(clone.querySelectorAll('[' + ATTR + ']'), function (node) {
- node.parentNode && node.parentNode.removeChild(node);
- });
- return String(clone.textContent || '');
- }
-
- function isEmpty() {
- var root = body();
- if (!root) {
- return false;
- }
- // 图片 / 视频 / 表格这类块即使没有文字也算有内容。
- if (root.querySelector(MEDIA)) {
- return false;
- }
- return contentText().replace(/[\s ]+/g, '') === '';
- }
-
- function hide() {
- Array.prototype.forEach.call(nodes(), function (node) {
- node.parentNode && node.parentNode.removeChild(node);
- });
- }
-
- function show() {
- var root = body();
- if (!root || root.querySelector('[' + ATTR + ']') || !isEmpty()) {
- return;
- }
- var paragraph = editor.dom.create('p', {
- 'class': 'jio-editor-placeholder',
- 'contenteditable': 'false',
- 'data-mce-bogus': 'all',
- 'data-jio-placeholder': '1',
- }, editor.dom.encode(text));
- root.insertBefore(paragraph, root.firstChild);
- }
-
- function sync() {
- if (isEmpty()) {
- show();
- } else {
- hide();
- }
- }
-
- // 占位段落不进入文章内容:序列化时剔除,解析(setContent)时同样剔除,
- // 避免把提示文字当成正文保存。
- editor.on('PreInit', function () {
- var strip = function (list) {
- list.forEach(function (node) {
- if (node.attr(ATTR)) {
- node.remove();
- }
- });
- };
- editor.serializer.addNodeFilter('p', strip);
- editor.parser.addNodeFilter('p', strip);
- });
-
- editor.on('init', sync);
- editor.on('SetContent input keyup change Undo Redo NodeChange', function () {
- // SetContent 之后 DOM 才稳定,放到下一帧再判断。
- window.setTimeout(sync, 0);
- });
-
- // 正文里只有占位段落时,光标没有可落点:补一个空段落并把光标放进去。
- var ensureCaret = function () {
- var root = body();
- if (!root) {
- return;
- }
- // 占位段落 pointer-events:none,点击会落到 body 本身;光标已经落在
- // 段落里时不要再动它,避免每次点击都挪动光标。
- var node = editor.selection.getNode();
- if (node && node.nodeType === 1 && node !== root && node.nodeName !== 'BODY') {
- return;
- }
- hide();
- if (!root.firstChild) {
- root.appendChild(editor.dom.create('p', {}, '
'));
- }
- editor.selection.setCursorLocation(root.firstChild, 0);
- };
- editor.on('click', ensureCaret);
- });
-})(window);
diff --git a/src/Core/CreatorStudio.php b/src/Core/CreatorStudio.php
index 2ad7a56b..1e451443 100644
--- a/src/Core/CreatorStudio.php
+++ b/src/Core/CreatorStudio.php
@@ -76,9 +76,9 @@ final class CreatorStudio {
$payload = $this->bootstrap();
$payload['creator']['contentCss'] = $content_css_url;
- // WordPress 打包的 TinyMCE 4.9 不含 placeholder 插件,
- // 占位符由主题插件实现,地址交给编辑器初始化。
- $payload['creator']['placeholderPlugin'] = $this->placeholder_plugin_url();
+ // 占位符与空白处落光标由主题插件实现(TinyMCE 4.9 没有 placeholder 插件),
+ // 地址交给编辑器初始化。
+ $payload['creator']['editorBasicsPlugin'] = $this->editor_basics_plugin_url();
$tinymce_data = $this->tinymce_payload($payload);
if (file_exists($plugin_path)) {
@@ -125,17 +125,17 @@ final class CreatorStudio {
}
/**
- * 正文占位符插件地址(带文件时间戳做缓存失效)。
+ * 正文基础插件地址(占位符 + 空白处落光标,带文件时间戳做缓存失效)。
*/
- private function placeholder_plugin_url(): string {
- $path = get_template_directory() . '/assets/js/front-submit-placeholder.js';
+ private function editor_basics_plugin_url(): string {
+ $path = get_template_directory() . '/assets/js/front-submit-editor-basics.js';
if (!file_exists($path)) {
return '';
}
return (string) add_query_arg(
'ver',
(string) filemtime($path),
- get_template_directory_uri() . '/assets/js/front-submit-placeholder.js'
+ get_template_directory_uri() . '/assets/js/front-submit-editor-basics.js'
);
}
diff --git a/templates/parts/submit/editor-main.php b/templates/parts/submit/editor-main.php
index fbfa9ba8..2bb6eef6 100644
--- a/templates/parts/submit/editor-main.php
+++ b/templates/parts/submit/editor-main.php
@@ -55,7 +55,7 @@ $is_can_new = !empty($ctx['is_can_new']);
$toolbar1[] = 'jio_hide';
}
$toolbar1 = implode(',', array_merge($toolbar1, ['jio_quote', 'jio_precode', 'fullscreen', 'wp_adv']));
- // jio_placeholder 通过 external_plugins 注册:WordPress 会丢弃
+ // jio_editor_basics 通过 external_plugins 注册:WordPress 会丢弃
// 名字已出现在 plugins 列表里的外部插件,写在这里反而加载不到。
$plugins = 'paste,link,lists,fullscreen,wordpress,wplink,charmap,colorpicker,textcolor';
$editor_plugin_path = get_template_directory() . '/assets/js/front-submit-editor.js';
@@ -63,20 +63,20 @@ $is_can_new = !empty($ctx['is_can_new']);
if (file_exists($editor_plugin_path)) {
$editor_plugin_url = add_query_arg('ver', (string) filemtime($editor_plugin_path), $editor_plugin_url);
}
- $placeholder_plugin_path = get_template_directory() . '/assets/js/front-submit-placeholder.js';
- $placeholder_plugin_url = get_template_directory_uri() . '/assets/js/front-submit-placeholder.js';
- if (file_exists($placeholder_plugin_path)) {
- $placeholder_plugin_url = add_query_arg('ver', (string) filemtime($placeholder_plugin_path), $placeholder_plugin_url);
+ $editor_basics_plugin_path = get_template_directory() . '/assets/js/front-submit-editor-basics.js';
+ $editor_basics_plugin_url = get_template_directory_uri() . '/assets/js/front-submit-editor-basics.js';
+ if (file_exists($editor_basics_plugin_path)) {
+ $editor_basics_plugin_url = add_query_arg('ver', (string) filemtime($editor_basics_plugin_path), $editor_basics_plugin_url);
}
$editor_css_path = get_template_directory() . '/assets/css/frontend/pages/submit-editor.css';
$editor_css_url = get_template_directory_uri() . '/assets/css/frontend/pages/submit-editor.css';
if (file_exists($editor_css_path)) {
$editor_css_url = add_query_arg('ver', (string) filemtime($editor_css_path), $editor_css_url);
}
- $external_plugins = static function (array $external, string $editor_id) use ($editor_plugin_url, $placeholder_plugin_url): array {
+ $external_plugins = static function (array $external, string $editor_id) use ($editor_plugin_url, $editor_basics_plugin_url): array {
if ($editor_id === 'post_content') {
$external['jio_editor_tools'] = $editor_plugin_url;
- $external['jio_placeholder'] = $placeholder_plugin_url;
+ $external['jio_editor_basics'] = $editor_basics_plugin_url;
}
return $external;
};
diff --git a/tests/widget-system/creator-studio-contract.test.cjs b/tests/widget-system/creator-studio-contract.test.cjs
index 53ecad51..0c1b1f54 100644
--- a/tests/widget-system/creator-studio-contract.test.cjs
+++ b/tests/widget-system/creator-studio-contract.test.cjs
@@ -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 段落插进正文(补空段落用的
是另一回事)。
+ 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', () => {
diff --git a/tests/widget-system/submit-placeholder-behavior.test.cjs b/tests/widget-system/submit-placeholder-behavior.test.cjs
new file mode 100644
index 00000000..9c6bbd21
--- /dev/null
+++ b/tests/widget-system/submit-placeholder-behavior.test.cjs
@@ -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(`

正文
code');
+ 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('正文
第二段
'); + 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( + '引用内容