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
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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', {}, '<br data-mce-bogus="1">');
|
||||
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);
|
||||
@@ -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', {}, '<br data-mce-bogus="1">'));
|
||||
}
|
||||
editor.selection.setCursorLocation(root.firstChild, 0);
|
||||
};
|
||||
editor.on('click', ensureCaret);
|
||||
});
|
||||
})(window);
|
||||
Reference in new issue
Block a user