From 5afa7ab9df914de5036ffe3626fc994f9d50a23c Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 9 Nov 2026 20:38:02 +0800 Subject: [PATCH] fix(submit): show body placeholder on first load and restore caret on blank clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个前台投稿编辑器的缺陷,根因都在占位符的实现方式: 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) --- .../src/editor-content-style.ts | 25 ++- assets/creator-studio/src/editor-runtime.ts | 7 +- assets/creator-studio/src/index.tsx | 6 +- assets/css/frontend/pages/submit-editor.css | 33 ++- assets/js/front-submit-editor-basics.js | 123 +++++++++++ assets/js/front-submit-placeholder.js | 134 ------------ src/Core/CreatorStudio.php | 14 +- templates/parts/submit/editor-main.php | 14 +- .../creator-studio-contract.test.cjs | 51 +++-- .../submit-placeholder-behavior.test.cjs | 193 ++++++++++++++++++ 10 files changed, 428 insertions(+), 172 deletions(-) create mode 100644 assets/js/front-submit-editor-basics.js delete mode 100644 assets/js/front-submit-placeholder.js create mode 100644 tests/widget-system/submit-placeholder-behavior.test.cjs 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(`${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('


', { 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('


', { 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('

', { 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('


', { 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('


', {}); + 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('

正文

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( + '

引用内容

' + ); + 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); +});