fix(submit): repair placeholder, code block, hidden block and settings toggles

- placeholder: hide the body ::before hint once the editor body has focus, so
  clicking an empty document no longer leaves the hint sitting under the caret
- code block: edit as a bare <pre> and convert to pre > code on save. TinyMCE
  treats pre > code as an inline boundary, so typing there leaked into the
  following paragraph; Enlighter also splits lines on newlines, so the <br>
  the editor produced rendered the whole block as one overflowing line
- code block: align the iframe rendering with Enlighter (Menlo 13px/1.4,
  #f8f9fa, 8px radius, 12px 17px padding, nowrap) in both content CSS files
- code block: move the caret into the block after insertion
- hidden block: hang the block-delete overlay on documentElement instead of
  body. data-mce-bogus only skips serialization, so the extra body child made
  mceInsertContent wipe the document and drop the first hidden block
- settings: give the toggle a real 44x24 hit area, align it to the top of the
  row, add aria-labels and show the disabled state
- styles: drop the duplicated @use/@import of publish-drawer and block-tools
- media insert/embed now serialize through serializeBlocks so code blocks are
  never stored in editor form
This commit is contained in:
kate522 committed 2026-11-10 16:04:49 +08:00
1 parent 4f9347b6aa
commit ec582ac9f9
11 files changed
+248 -56

No files matched your search

@@ -241,6 +241,7 @@ export function PublishSettings({
<input
type="checkbox"
checked={pay.enabled}
aria-label={__('开启付费内容', 'jio')}
onChange={(event) => {
const enabled = event.target.checked;
onPayEnabledChange(enabled);
@@ -269,6 +270,7 @@ export function PublishSettings({
type="checkbox"
checked={scheduleEnabled}
disabled={!canSchedule}
aria-label={__('开启定时发布', 'jio')}
onChange={(event) => onScheduleChange(event.target.checked, scheduleAt || defaultScheduleTime())}
/>
<span aria-hidden="true" />
@@ -34,6 +34,9 @@ p { margin: 0 0 1.1em; }
* (front-submit-editor-basics.js)在正文为空时给 body 打 data-jio-empty,
* 这里用 ::before 渲染提示。不插 DOM 节点,避免被 setContent 清掉、
* 也避免占住首行挡住点击。
*
* 点击正文(body 获得焦点)后就不再显示:提示文字压在光标位置上,作者会以为
* 点击没生效。焦点离开且正文仍为空时再出现。
*/
body[data-jio-empty="1"]::before {
position: absolute;
@@ -44,6 +47,7 @@ body[data-jio-empty="1"]::before {
pointer-events: none;
user-select: none;
}
body[data-jio-empty="1"]:focus::before { display: none; }
h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.65em; line-height: 1.4; }
img, iframe { max-width: 100%; }
@@ -110,21 +114,27 @@ img { height: auto; }
}
/*
* 代码块:与前台文章页保持一致,只用 pre > code。
* 前台由 EnlighterJS 高亮,编辑器里给出同一份底色与等宽排版。
* 代码块:与前台文章页一致。
*
* 前台由 EnlighterJS 把 pre > code 重绘成 .enlighter-default:Menlo 13px/
* 18.2px、#f8f9fa 底色、8px 圆角、上下 20px 间距、行内 12px 内边距,超长行
* 横向滚动而不换行。编辑器里只有裸 pre(保存前才套回 code),所以这里直接
* 复刻同一组数值;换行行为必须保持 nowrap,否则编辑器里看到的是折行、
* 发布后却是横向滚动。
*/
pre {
margin: 0 0 1.1em;
padding: 14px 18px;
margin: 0 0 20px;
padding: 12px 17px;
overflow-x: auto;
overflow-y: hidden;
border-radius: 8px;
background: #f2f4f5;
color: #2b2b2b;
font-family: Consolas, Monaco, "Courier New", monospace;
background: #f8f9fa;
color: #555;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.4;
white-space: pre;
tab-size: 4;
}
pre code {
border: 0;
+8 -5
View File
@@ -125,11 +125,14 @@ const SELECTED_CLASS = 'jio-creator__block-selected';
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
*
* 与 WPS 一致的两步删除:第一次点击只是选中该块并高亮,第二次点击才真正删除。
* 选中态下按钮改为醒目的红色「再次点击删除」,且鼠标移开不会消失,避免误删。
* 选中态下按钮改为醒目的「再次点击删除」,且鼠标移开不会消失,避免误删。
*
* 浮层直接插在 iframe 的 body 里,因此用 data-mce-bogus 让 TinyMCE 在
* 序列化、字数和选区计算时完全忽略它;它的样式写在 editor-content-style.ts
* (父页面样式表管不到 iframe 内部)。
* 浮层挂在 iframe 的 `documentElement` 而不是 `body` 里:`data-mce-bogus` 只让
* TinyMCE 跳过序列化,选区计算仍会把它算进 body 的子节点;body 里多一个非常规
* 子节点时,插入块(例如「隐藏」)会在 mceInsertContent 里把整篇正文清空。挂在
* html 上既不影响正文,也能用同一个坐标系定位。
*
* 它的样式写在 editor-content-style.ts(父页面样式表管不到 iframe 内部)。
*/
export function installBlockActions(editor: any): void {
const doc = editor.getDoc();
@@ -146,7 +149,7 @@ export function installBlockActions(editor: any): void {
'<span class="jio-creator__block-action-icon" aria-hidden="true">&#10005;</span>' +
'<span class="jio-creator__block-action-text"></span>' +
'</button>';
doc.body.appendChild(bar);
doc.documentElement.appendChild(bar);
const button: HTMLButtonElement | null = bar.querySelector('[data-jio-block-delete]');
const text: HTMLElement | null = bar.querySelector('.jio-creator__block-action-text');
+29 -4
View File
@@ -540,6 +540,31 @@ function Studio(): JSX.Element {
syncEditorHeight(editor);
}, [focusEditor]);
/**
* 代码块:插入后把光标直接放进代码块,作者可以立刻开始敲代码。
*
* `insertContent` 结束后光标落在块后的空段落里,这对外层结构没问题,但
* 对代码块不合适 —— 作者会以为插进来的块只是占位。
*/
const insertCodeBlock = useCallback(() => {
const editor = focusEditor();
if (!editor) {
return;
}
editor.undoManager.transact(() => {
editor.insertContent(codeBlockMarkup());
});
const pre = editor.selection.getNode()?.closest?.('pre')
|| Array.from(editor.getBody().querySelectorAll('pre')).pop();
if (pre) {
editor.selection.setCursorLocation(pre, 0);
}
editor.nodeChanged();
updateContent(serializeBlocks(editor.getContent()));
updateHistoryAvailability(editor);
syncEditorHeight(editor);
}, [focusEditor]);
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
const insertBlock = useCallback((kind: string) => {
rememberSelection();
@@ -548,7 +573,7 @@ function Studio(): JSX.Element {
return;
}
if (kind === 'code') {
insertHtml(codeBlockMarkup());
insertCodeBlock();
return;
}
if (kind === 'ul' || kind === 'ol') {
@@ -565,7 +590,7 @@ function Studio(): JSX.Element {
if (kind === 'divider') {
insertHtml(dividerMarkup());
}
}, [focusEditor, insertHtml, rememberSelection, updateBlockValue]);
}, [focusEditor, insertCodeBlock, insertHtml, rememberSelection, updateBlockValue]);
/**
* 把当前选区(或光标所在的顶层段落)替换成一个整块。
@@ -682,7 +707,7 @@ function Studio(): JSX.Element {
if (markup) {
target.insertContent(markup);
target.nodeChanged();
updateContent(target.getContent());
updateContent(serializeBlocks(target.getContent()));
updateHistoryAvailability(target);
}
},
@@ -691,7 +716,7 @@ function Studio(): JSX.Element {
if (target) {
target.insertContent(`${html}<p></p>`);
target.nodeChanged();
updateContent(target.getContent());
updateContent(serializeBlocks(target.getContent()));
updateHistoryAvailability(target);
}
},
@@ -135,6 +135,7 @@ export function PayPanel({
<input
type="checkbox"
checked={pay.enabled}
aria-label={__('开启付费内容', 'jio')}
onChange={(event) => patch({ enabled: event.target.checked })}
/>
<span aria-hidden="true" />
@@ -201,32 +201,51 @@
}
}
/*
* 开关:44×24 的胶囊 + 20px 圆钮。
*
* 触发器是包住 input 的 label,尺寸与胶囊完全一致(行高会被 label 撑出
* 3px 的空白,导致它在行内看起来偏下)。整块可点:点击胶囊任意位置都能
* 切换,而不是只有那个 1px 的隐藏 input。
*/
.jio-creator-toggle {
position: relative;
display: inline-flex;
flex: 0 0 auto;
width: 44px;
height: 24px;
align-items: center;
justify-content: center;
margin: 0;
line-height: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
span {
display: block;
width: 42px;
height: 24px;
width: 100%;
height: 100%;
border-radius: 999px;
background: #d6dfda;
transition: background 0.18s ease;
&::after {
display: block;
width: 18px;
height: 18px;
margin: 3px;
width: 20px;
height: 20px;
margin: 2px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(24, 39, 34, 0.18);
content: "";
transition: transform 0.18s ease;
}
@@ -237,13 +256,22 @@
}
input:checked + span::after {
transform: translateX(18px);
transform: translateX(20px);
}
input:focus-visible + span {
outline: 2px solid $accent;
outline-offset: 2px;
}
input:disabled + span {
opacity: 0.45;
}
/* 不可用时整块也不能点,避免看起来可切换。 */
&:has(input:disabled) {
cursor: default;
}
}
.jio-creator-pay {
+8 -4
View File
@@ -7,6 +7,7 @@
@use './tokens' as *;
@use './publish-drawer' as *;
@use './block-tools';
/*
@@ -636,9 +637,15 @@ body.jio-page-submit.has-static-nav {
font-weight: 600;
}
/*
* 设置行:说明在左、控件在右。
*
* 控件顶对齐而不是垂直居中:说明文字折成两行时,居中的开关会悬在行中间,
* 与标题对不上,看起来像是错位。
*/
.jio-creator__setting-row {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 16px 0;
@@ -808,9 +815,6 @@ body.jio-page-submit.has-static-nav {
}
}
@import './_publish-drawer';
@import './_block-tools';
/* ---------- 手机 / 平板 ---------- */
@media (max-width: 767px) {
@@ -29,13 +29,15 @@ export function dividerMarkup(): string {
}
/**
* 代码块:与前台文章页一致,只落 pre > code。
* 代码块。
*
* 前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,因此正文里不再写
* 语言 / 主题属性,也不需要编辑器内的语言角标。
* 编辑器里放一个可直接输入的裸 `<pre>`:TinyMCE 会把 `pre > code` 当成行内边界,
* 光标落不进去、输入会把内容拆到段落里。落库前由 `serializeBlocks()` 换成前台
* Enlighter 认得的 `pre > code`(换行写成真正的换行符),打开已有内容时
* `hydrateBlocks()` 再还原成可编辑的 `<pre>`。
*/
export function codeBlockMarkup(): string {
return '<pre><code></code></pre><p></p>';
return '<pre><br></pre><p></p>';
}
export type TableOptions = {
+102 -5
View File
@@ -2,9 +2,14 @@
* 编辑器内容 ↔ 保存格式的转换。
*
* 编辑器里把公式、注释渲染成可读的样子(方便写作时看到最终效果),
* 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容与代码块本身就是
* 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容本身就是
* 短代码 / 前台结构,不需要转换。
*
* 代码块例外:前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,认的是
* `pre > code`,并把源码里的换行当作分行;而 TinyMCE 把 `pre > code` 当成
* 行内边界,光标落不进去、输入会被拆成段落。因此编辑器里用裸 `<pre>` 编辑,
* 保存前套回 `pre > code` 并把 `<br>` 还原成换行符。
*
* @package Jio
*/
@@ -17,17 +22,104 @@ const decodeEntities = (value: string): string => {
return area.value;
};
/**
* 读出代码块的源码行。
*
* 两种形态都要认:编辑器里的裸 `<pre>`(换行是 `<br>`),以及前台落库的
* `pre > code`(换行是文本里的 `\n`)。`<code>` 只是容器,直接走进去读,
* 不重复计数。
*/
const readCodeLines = (pre: Element): string[] => {
const lines: string[] = [];
let current = '';
const flush = (): void => {
lines.push(current);
current = '';
};
const walk = (node: Node): void => {
node.childNodes.forEach((child) => {
if (child.nodeType === 3) {
String(child.nodeValue || '')
.replace(/ /g, ' ')
.split('\n')
.forEach((part, index) => {
if (index > 0) {
flush();
}
current += part;
});
return;
}
if (child.nodeType !== 1) {
return;
}
if ((child as Element).nodeName === 'BR') {
flush();
return;
}
walk(child);
});
};
walk(pre);
flush();
// 末尾的空行只是排版残留(裸 pre 收尾的 <br>、落库时补的换行符)。
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
return lines;
};
/**
* 编辑器里的裸 `<pre>` → 前台的 `pre > code`。
*
* `<br>` 与文本里的换行都换成分行符:Enlighter 直接按文本切行,留着 `<br>`
* 会把整块压成一行、长行溢出卡片。
*/
const serializeCodeBlock = (pre: Element): void => {
const lines = readCodeLines(pre);
const code = document.createElement('code');
code.textContent = lines.length === 0 ? '' : `${lines.join('\n')}\n`;
pre.replaceChildren(code);
};
/**
* 前台 `pre > code` → 编辑器里可直接输入的裸 `<pre>`。
*/
const hydrateCodeBlock = (pre: Element): void => {
const lines = readCodeLines(pre);
pre.replaceChildren();
if (lines.length === 0) {
pre.appendChild(document.createElement('br'));
return;
}
lines.forEach((line, index) => {
if (index > 0) {
pre.appendChild(document.createElement('br'));
}
if (line !== '') {
pre.appendChild(document.createTextNode(line));
}
});
};
/**
* 保存前:把编辑器里的可视化块换回短代码 / 纯标记。
*/
export function serializeBlocks(html: string): string {
if (!html || html.indexOf('data-jio-') === -1) {
if (!html) {
return html;
}
const container = document.createElement('div');
container.innerHTML = html;
// 代码块:裸 pre → 前台的 pre > code(换行写成换行符)。
container.querySelectorAll('pre').forEach((pre) => serializeCodeBlock(pre));
// 公式
container.querySelectorAll<HTMLElement>('[data-jio-math-source]').forEach((node) => {
const formula = node.getAttribute('data-jio-math-source') || '';
@@ -51,14 +143,19 @@ export function serializeBlocks(html: string): string {
}
/**
* 打开已有内容时:把短代码还原成编辑器里的可视化块。
* 打开已有内容时:把短代码 / 前台的代码块结构还原成编辑器里的可视化块。
*/
export function hydrateBlocks(html: string): string {
if (!html || html.indexOf('[jio_') === -1) {
if (!html) {
return html;
}
return html
const container = document.createElement('div');
container.innerHTML = html;
container.querySelectorAll('pre').forEach((pre) => hydrateCodeBlock(pre));
return container.innerHTML
.replace(
/\[jio_math display="([01])"\]([\s\S]*?)\[\/jio_math\]/g,
(_match, display: string, formula: string) =>
+18 -10
View File
@@ -115,19 +115,22 @@
--quote-color: #23790e;
}
/* 代码块:与前台文章页一致,只用 pre > code,由 EnlighterJS 在发布页高亮。 */
/* 代码块:与前台文章页一致(EnlighterJS 的 .enlighter-default 外观)。
* 编辑器里是裸 pre,保存前才套回 pre > code,因此换行行为必须与前台一致:
* 超长行横向滚动,不折行。 */
pre {
margin: 0 0 1.1em;
padding: 14px 18px;
margin: 0 0 20px;
padding: 12px 17px;
overflow-x: auto;
overflow-y: hidden;
border-radius: 8px;
background: #f2f4f5;
color: #2b2b2b;
font-family: Consolas, Monaco, "Courier New", monospace;
background: #f8f9fa;
color: #555;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.4;
white-space: pre;
tab-size: 4;
}
pre code {
@@ -212,7 +215,8 @@ pre code {
* 刻意不插 DOM 节点:插入的段落会被 setContent 清掉(首次加载不显示),
* 也会占住首行、挡住点击空白处落光标。
*
* 绝对定位覆盖在空段落上,与原生 placeholder 的观感一致。
* 绝对定位覆盖在空段落上,与原生 placeholder 的观感一致;点击正文(body
* 获得焦点)后立即隐藏,否则提示会压在光标上,让人以为点击没生效。
*/
body[data-jio-empty="1"]::before {
position: absolute;
@@ -224,6 +228,10 @@ body[data-jio-empty="1"]::before {
user-select: none;
}
body[data-jio-empty="1"]:focus::before {
display: none;
}
/*
* body 撑满编辑区:内容很短时点击正文下方的空白区域也应落光标,
* 否则只有文字所在的那一行能点。
@@ -207,14 +207,16 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
// 表格 / 公式 / 注释 / 代码都走参数弹窗,不再是固定内容。
assert.match(appSource, /<BlockDialog/);
assert.match(appSource, /setBlockDialog\(kind as BlockDialogKind\)/);
// 代码块直接插入,不弹窗、不选语言。
assert.match(appSource, /insertHtml\(codeBlockMarkup\(\)\)/);
// 代码块直接插入,不弹窗、不选语言;插入后光标留在代码块里。
assert.match(appSource, /insertCodeBlock\(\)/);
assert.match(appSource, /editor\.selection\.setCursorLocation\(pre, 0\)/);
assert.doesNotMatch(appSource, /setCodeDialog/);
assert.doesNotMatch(read('assets/creator-studio/src/components/block-dialog.tsx'), /kind === 'code'/);
assert.match(read('assets/creator-studio/src/components/block-dialog.tsx'), /onSubmit\(\{ kind, rows: clamp\(rows, 1, 20\), cols: clamp\(cols, 1, 10\), header, striped \}\)/);
assert.match(appSource, /onInsertBlock=\{insertBlock\}/);
assert.match(appSource, /onInsertQuote=\{insertQuote\}/);
const markup = read('assets/creator-studio/src/utils/block-markup.ts');
const serialize = read('assets/creator-studio/src/utils/serialize.ts');
assert.match(markup, /jio-math/);
// 引用沿用主题的 quote_q 结构与配色类。
assert.match(markup, /quote_q/);
@@ -225,16 +227,23 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
assert.match(markup, /data-jio-math-source/);
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_math display=/);
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_footnote\]/);
// 代码块与前台文章页一致:pre > code,不写语言 / 主题属性。
assert.match(markup, /<pre><code><\/code><\/pre>/);
// 代码块:编辑器里是裸 pre(TinyMCE 把 pre > code 当行内边界,光标落不进去),
// 保存时才由 serializeBlocks 套回前台的 pre > code。不写语言 / 主题属性。
assert.match(markup, /<pre><br><\/pre><p><\/p>/);
assert.doesNotMatch(markup, /<pre><code><\/code><\/pre>/);
assert.match(serialize, /querySelectorAll\('pre'\)[\s\S]*?serializeCodeBlock/);
assert.match(serialize, /createElement\('code'\)/);
assert.match(serialize, /hydrateCodeBlock/);
assert.doesNotMatch(markup, /enlighter-pre/);
assert.doesNotMatch(markup, /data-enlighter-language/);
assert.doesNotMatch(read('assets/creator-studio/src/editor-runtime.ts'), /syncCodeLabels/);
assert.doesNotMatch(read('assets/creator-studio/src/components/block-dialog.tsx'), /CODE_LANGUAGES/);
// 外观:编辑器 iframe 与前台投稿编辑器保持同一套代码块样式。
// 外观:编辑器 iframe 与前台 Enlighter 渲染值一致(不折行、浅底、8px 圆角)。
const editorCss = read('assets/css/frontend/pages/submit-editor.css');
assert.doesNotMatch(editorCss, /\.enlighter-pre/);
assert.match(editorCss, /pre code \{/);
assert.match(editorCss, /pre \{[^}]*white-space: pre;/);
assert.match(editorCss, /pre \{[^}]*background: #f8f9fa/);
// 隐藏内容沿用 .tinymce-hide 结构,短代码作为文本落库。
assert.match(markup, /tinymce-hide/);
assert.match(markup, /\[jio_hide mode="login"\]/);
@@ -301,13 +310,16 @@ test('top bar is masthead + centered toolbar, with no brand text in the toolbar
assert.doesNotMatch(rest, /'home'\s*=>\s*esc_url_raw\(\$home\)/);
});
test('block delete overlay lives inside the editor iframe and stays hidden', () => {
test('block delete overlay lives outside the editable body and stays hidden', () => {
const runtime = read('assets/creator-studio/src/editor-runtime.ts');
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
const pageStyles = read('assets/creator-studio/src/styles/_block-tools.scss');
// 浮层插在 TinyMCE iframe 的 body 里。
assert.match(runtime, /doc\.body\.appendChild\(bar\)/);
// 浮层挂在 iframe 的 documentElement 上,不能进 body:data-mce-bogus 只跳过
// 序列化,body 里多一个非常规子节点会让 mceInsertContent 把整篇正文清空
// (表现为「第一次点隐藏块没有插进正文」)。
assert.match(runtime, /doc\.documentElement\.appendChild\(bar\)/);
assert.doesNotMatch(runtime, /doc\.body\.appendChild\(bar\)/);
assert.match(runtime, /bar\.id = 'jio-block-actions'/);
// iframe 是独立文档:页面级样式表管不到它,隐藏规则必须写在 content_style。
assert.match(contentStyle, /#jio-block-actions\s*\{[^}]*position:\s*absolute/);