fix(submit): render the configured body placeholder in TinyMCE
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
beb898b1ab
commit
44a03fd5f4
8 files changed
+228
-3
No files matched your search
@@ -19,6 +19,17 @@ body {
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
p { margin: 0 0 1.1em; }
|
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; }
|
h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.65em; line-height: 1.4; }
|
||||||
img, iframe { max-width: 100%; }
|
img, iframe { max-width: 100%; }
|
||||||
img { height: auto; }
|
img { height: auto; }
|
||||||
|
|||||||
@@ -376,6 +376,10 @@ function Studio(): JSX.Element {
|
|||||||
toolbar2: '',
|
toolbar2: '',
|
||||||
toolbar3: '',
|
toolbar3: '',
|
||||||
plugins: 'paste,link,lists,wordpress,wplink',
|
plugins: 'paste,link,lists,wordpress,wplink',
|
||||||
|
// WordPress 打包的 TinyMCE 没有 placeholder 插件,占位符由主题插件实现。
|
||||||
|
external_plugins: bootstrap.creator?.placeholderPlugin
|
||||||
|
? { jio_placeholder: bootstrap.creator.placeholderPlugin }
|
||||||
|
: undefined,
|
||||||
wpautop: true,
|
wpautop: true,
|
||||||
body_class: 'jio-creator__tinymce',
|
body_class: 'jio-creator__tinymce',
|
||||||
content_css: bootstrap.creator?.contentCss || '',
|
content_css: bootstrap.creator?.contentCss || '',
|
||||||
|
|||||||
@@ -203,3 +203,12 @@ pre code {
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
vertical-align: super;
|
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;
|
||||||
|
}
|
||||||
@@ -526,8 +526,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateCount() {
|
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();
|
var element = getCounter();
|
||||||
if (element) {
|
if (element) {
|
||||||
element.textContent = '字数:' + count;
|
element.textContent = '字数:' + count;
|
||||||
|
|||||||
@@ -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', {}, '<br data-mce-bogus="1">'));
|
||||||
|
}
|
||||||
|
editor.selection.setCursorLocation(root.firstChild, 0);
|
||||||
|
};
|
||||||
|
editor.on('click', ensureCaret);
|
||||||
|
});
|
||||||
|
})(window);
|
||||||
@@ -76,6 +76,9 @@ final class CreatorStudio {
|
|||||||
|
|
||||||
$payload = $this->bootstrap();
|
$payload = $this->bootstrap();
|
||||||
$payload['creator']['contentCss'] = $content_css_url;
|
$payload['creator']['contentCss'] = $content_css_url;
|
||||||
|
// WordPress 打包的 TinyMCE 4.9 不含 placeholder 插件,
|
||||||
|
// 占位符由主题插件实现,地址交给编辑器初始化。
|
||||||
|
$payload['creator']['placeholderPlugin'] = $this->placeholder_plugin_url();
|
||||||
$tinymce_data = $this->tinymce_payload($payload);
|
$tinymce_data = $this->tinymce_payload($payload);
|
||||||
|
|
||||||
if (file_exists($plugin_path)) {
|
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 {
|
private function tinymce_payload(array $payload): array {
|
||||||
$capabilities = is_array($payload['capabilities'] ?? null) ? $payload['capabilities'] : [];
|
$capabilities = is_array($payload['capabilities'] ?? null) ? $payload['capabilities'] : [];
|
||||||
$base = rest_url(CreatorRest::NS);
|
$base = rest_url(CreatorRest::NS);
|
||||||
|
|||||||
@@ -55,20 +55,28 @@ $is_can_new = !empty($ctx['is_can_new']);
|
|||||||
$toolbar1[] = 'jio_hide';
|
$toolbar1[] = 'jio_hide';
|
||||||
}
|
}
|
||||||
$toolbar1 = implode(',', array_merge($toolbar1, ['jio_quote', 'jio_precode', 'fullscreen', 'wp_adv']));
|
$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';
|
$plugins = 'paste,link,lists,fullscreen,wordpress,wplink,charmap,colorpicker,textcolor';
|
||||||
$editor_plugin_path = get_template_directory() . '/assets/js/front-submit-editor.js';
|
$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';
|
$editor_plugin_url = get_template_directory_uri() . '/assets/js/front-submit-editor.js';
|
||||||
if (file_exists($editor_plugin_path)) {
|
if (file_exists($editor_plugin_path)) {
|
||||||
$editor_plugin_url = add_query_arg('ver', (string) filemtime($editor_plugin_path), $editor_plugin_url);
|
$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_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';
|
$editor_css_url = get_template_directory_uri() . '/assets/css/frontend/pages/submit-editor.css';
|
||||||
if (file_exists($editor_css_path)) {
|
if (file_exists($editor_css_path)) {
|
||||||
$editor_css_url = add_query_arg('ver', (string) filemtime($editor_css_path), $editor_css_url);
|
$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') {
|
if ($editor_id === 'post_content') {
|
||||||
$external['jio_editor_tools'] = $editor_plugin_url;
|
$external['jio_editor_tools'] = $editor_plugin_url;
|
||||||
|
$external['jio_placeholder'] = $placeholder_plugin_url;
|
||||||
}
|
}
|
||||||
return $external;
|
return $external;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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*#/);
|
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', () => {
|
test('creator studio build artifacts and contracts exist', () => {
|
||||||
assert.ok(exists('assets/creator-studio/package.json'));
|
assert.ok(exists('assets/creator-studio/package.json'));
|
||||||
assert.ok(exists('assets/creator-studio/package-lock.json'));
|
assert.ok(exists('assets/creator-studio/package-lock.json'));
|
||||||
|
|||||||
Reference in new issue
Block a user