From 44a03fd5f4ca72fba72f273858a43c6d50d267ad Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 9 Nov 2026 19:01:28 +0800 Subject: [PATCH] fix(submit): render the configured body placeholder in TinyMCE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress 打包的 TinyMCE 4.9 不含官方 placeholder 插件,tinymce.init 里的 placeholder 配置被静默忽略,投稿页「投稿内容占位符」从未显示过。 新增主题插件 assets/js/front-submit-placeholder.js:正文为空时插入一个 data-mce-bogus 的占位段落,有内容即移除;序列化 / 解析都剔除该段落, 字数统计也跳过它,提示文字不会写进文章。 - 经典投稿表单与创作者工作台都通过 external_plugins 加载 (WordPress 会丢弃名字已在 plugins 列表里的外部插件) - 占位样式写进 iframe 的 content CSS:submit-editor.css 与 editor-content-style.ts Co-Authored-By: Claude Opus 5 (1M context) --- .../src/editor-content-style.ts | 11 ++ assets/creator-studio/src/index.tsx | 4 + assets/css/frontend/pages/submit-editor.css | 9 ++ assets/js/front-submit-editor.js | 13 +- assets/js/front-submit-placeholder.js | 134 ++++++++++++++++++ src/Core/CreatorStudio.php | 18 +++ templates/parts/submit/editor-main.php | 10 +- .../creator-studio-contract.test.cjs | 32 +++++ 8 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 assets/js/front-submit-placeholder.js diff --git a/assets/creator-studio/src/editor-content-style.ts b/assets/creator-studio/src/editor-content-style.ts index 49586a65..15f20bea 100644 --- a/assets/creator-studio/src/editor-content-style.ts +++ b/assets/creator-studio/src/editor-content-style.ts @@ -19,6 +19,17 @@ body { letter-spacing: 0.01em; } p { margin: 0 0 1.1em; } + +/* + * 正文占位符:TinyMCE 4.9 没有内置 placeholder 插件,由主题插件 + * (front-submit-placeholder.js)在正文为空时插入这个不可编辑的段落。 + */ +.jio-editor-placeholder { + color: #b9bfbc; + cursor: text; + pointer-events: none; + user-select: none; +} 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/index.tsx b/assets/creator-studio/src/index.tsx index b8b607b5..815da637 100644 --- a/assets/creator-studio/src/index.tsx +++ b/assets/creator-studio/src/index.tsx @@ -376,6 +376,10 @@ 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 } + : undefined, wpautop: true, body_class: 'jio-creator__tinymce', content_css: bootstrap.creator?.contentCss || '', diff --git a/assets/css/frontend/pages/submit-editor.css b/assets/css/frontend/pages/submit-editor.css index b1fc2905..f0eee69c 100644 --- a/assets/css/frontend/pages/submit-editor.css +++ b/assets/css/frontend/pages/submit-editor.css @@ -203,3 +203,12 @@ pre code { font-size: 0.8em; vertical-align: super; } + +/* 正文占位符(TinyMCE 4.9 没有内置 placeholder 插件,由主题插件插入) */ +.mce-content-body .jio-editor-placeholder, +.jio-editor-placeholder { + color: #b9bfbc; + cursor: text; + pointer-events: none; + user-select: none; +} diff --git a/assets/js/front-submit-editor.js b/assets/js/front-submit-editor.js index 93b29918..d8d17684 100644 --- a/assets/js/front-submit-editor.js +++ b/assets/js/front-submit-editor.js @@ -526,8 +526,17 @@ } function updateCount() { - var count = String((editor.getBody() && editor.getBody().textContent) || '') - .replace(/\s+/g, '').length; + // 正文占位符是编辑器插入的提示段落,不算作者写的字。 + var body = editor.getBody(); + var text = ''; + if (body) { + var clone = body.cloneNode(true); + Array.prototype.forEach.call(clone.querySelectorAll('[data-jio-placeholder]'), function (node) { + node.parentNode && node.parentNode.removeChild(node); + }); + text = String(clone.textContent || ''); + } + var count = text.replace(/\s+/g, '').length; var element = getCounter(); if (element) { element.textContent = '字数:' + count; diff --git a/assets/js/front-submit-placeholder.js b/assets/js/front-submit-placeholder.js new file mode 100644 index 00000000..acbaff9f --- /dev/null +++ b/assets/js/front-submit-placeholder.js @@ -0,0 +1,134 @@ +/** + * 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 a5ab5296..2ad7a56b 100644 --- a/src/Core/CreatorStudio.php +++ b/src/Core/CreatorStudio.php @@ -76,6 +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_data = $this->tinymce_payload($payload); if (file_exists($plugin_path)) { @@ -121,6 +124,21 @@ final class CreatorStudio { ); } + /** + * 正文占位符插件地址(带文件时间戳做缓存失效)。 + */ + private function placeholder_plugin_url(): string { + $path = get_template_directory() . '/assets/js/front-submit-placeholder.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' + ); + } + private function tinymce_payload(array $payload): array { $capabilities = is_array($payload['capabilities'] ?? null) ? $payload['capabilities'] : []; $base = rest_url(CreatorRest::NS); diff --git a/templates/parts/submit/editor-main.php b/templates/parts/submit/editor-main.php index a5ea39eb..fbfa9ba8 100644 --- a/templates/parts/submit/editor-main.php +++ b/templates/parts/submit/editor-main.php @@ -55,20 +55,28 @@ $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 会丢弃 + // 名字已出现在 plugins 列表里的外部插件,写在这里反而加载不到。 $plugins = 'paste,link,lists,fullscreen,wordpress,wplink,charmap,colorpicker,textcolor'; $editor_plugin_path = get_template_directory() . '/assets/js/front-submit-editor.js'; $editor_plugin_url = get_template_directory_uri() . '/assets/js/front-submit-editor.js'; 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_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): array { + $external_plugins = static function (array $external, string $editor_id) use ($editor_plugin_url, $placeholder_plugin_url): array { if ($editor_id === 'post_content') { $external['jio_editor_tools'] = $editor_plugin_url; + $external['jio_placeholder'] = $placeholder_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 af4c4b7a..53ecad51 100644 --- a/tests/widget-system/creator-studio-contract.test.cjs +++ b/tests/widget-system/creator-studio-contract.test.cjs @@ -377,6 +377,38 @@ test('quote, hidden content, and block math render like the live article', () => assert.doesNotMatch(contentStyle, /\.jio-math--block\s*\{[^}]*background:\s*#/); }); +test('submit editor renders the configured body placeholder', () => { + const plugin = read('assets/js/front-submit-placeholder.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'); + + // WordPress 打包的 TinyMCE 4.9 没有 placeholder 插件,主题自己实现。 + assert.match(plugin, /PluginManager\.add\('jio_placeholder'/); + assert.match(plugin, /getParam\('placeholder'/); + assert.match(plugin, /data-mce-bogus/); + // 占位段落不能写进文章内容。 + assert.match(plugin, /addNodeFilter\('p', strip\)/); + // 图片 / 视频 / 表格即使没有文字也算有内容。 + assert.match(plugin, /MEDIA = 'img,iframe,table,pre,hr,video,audio,figure/); + + // 两条投稿路径都要加载插件,否则占位符在其中一个页面消失。 + // 注意:WordPress 会丢弃名字已出现在 plugins 列表里的外部插件, + // 所以 jio_placeholder 只能走 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.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/); + + // 占位符样式必须写进 iframe 的 content CSS,页面样式影响不到编辑器内部。 + assert.match(styles, /\.jio-editor-placeholder\s*\{/); + assert.match(contentStyle, /\.jio-editor-placeholder\s*\{/); +}); + test('creator studio build artifacts and contracts exist', () => { assert.ok(exists('assets/creator-studio/package.json')); assert.ok(exists('assets/creator-studio/package-lock.json'));