diff --git a/assets/creator-studio/src/components/publish-settings.tsx b/assets/creator-studio/src/components/publish-settings.tsx index a9813034..c3609bb8 100644 --- a/assets/creator-studio/src/components/publish-settings.tsx +++ b/assets/creator-studio/src/components/publish-settings.tsx @@ -241,6 +241,7 @@ export function PublishSettings({ { 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())} /> diff --git a/assets/creator-studio/src/editor-content-style.ts b/assets/creator-studio/src/editor-content-style.ts index 6348ea7b..2a40e3af 100644 --- a/assets/creator-studio/src/editor-content-style.ts +++ b/assets/creator-studio/src/editor-content-style.ts @@ -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; diff --git a/assets/creator-studio/src/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts index 4be834c1..ddda5df5 100644 --- a/assets/creator-studio/src/editor-runtime.ts +++ b/assets/creator-studio/src/editor-runtime.ts @@ -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 { '' + '' + ''; - 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'); diff --git a/assets/creator-studio/src/index.tsx b/assets/creator-studio/src/index.tsx index 9e5242eb..332bd059 100644 --- a/assets/creator-studio/src/index.tsx +++ b/assets/creator-studio/src/index.tsx @@ -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}
`); target.nodeChanged(); - updateContent(target.getContent()); + updateContent(serializeBlocks(target.getContent())); updateHistoryAvailability(target); } }, diff --git a/assets/creator-studio/src/panels/pay-panel.tsx b/assets/creator-studio/src/panels/pay-panel.tsx index 9bb50644..14c733f3 100644 --- a/assets/creator-studio/src/panels/pay-panel.tsx +++ b/assets/creator-studio/src/panels/pay-panel.tsx @@ -135,6 +135,7 @@ export function PayPanel({ patch({ enabled: event.target.checked })} /> diff --git a/assets/creator-studio/src/styles/_publish-drawer.scss b/assets/creator-studio/src/styles/_publish-drawer.scss index c9f59068..192a0c01 100644 --- a/assets/creator-studio/src/styles/_publish-drawer.scss +++ b/assets/creator-studio/src/styles/_publish-drawer.scss @@ -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 { diff --git a/assets/creator-studio/src/styles/studio.scss b/assets/creator-studio/src/styles/studio.scss index 55da60c6..6c8186c6 100644 --- a/assets/creator-studio/src/styles/studio.scss +++ b/assets/creator-studio/src/styles/studio.scss @@ -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) { diff --git a/assets/creator-studio/src/utils/block-markup.ts b/assets/creator-studio/src/utils/block-markup.ts index db94c7ca..8e3046ae 100644 --- a/assets/creator-studio/src/utils/block-markup.ts +++ b/assets/creator-studio/src/utils/block-markup.ts @@ -29,13 +29,15 @@ export function dividerMarkup(): string { } /** - * 代码块:与前台文章页一致,只落 pre > code。 + * 代码块。 * - * 前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,因此正文里不再写 - * 语言 / 主题属性,也不需要编辑器内的语言角标。 + * 编辑器里放一个可直接输入的裸 ``:TinyMCE 会把 `pre > code` 当成行内边界,
+ * 光标落不进去、输入会把内容拆到段落里。落库前由 `serializeBlocks()` 换成前台
+ * Enlighter 认得的 `pre > code`(换行写成真正的换行符),打开已有内容时
+ * `hydrateBlocks()` 再还原成可编辑的 ``。
*/
export function codeBlockMarkup(): string {
- return '
';
+ return '
';
}
export type TableOptions = {
diff --git a/assets/creator-studio/src/utils/serialize.ts b/assets/creator-studio/src/utils/serialize.ts
index 6cef20b1..25d4d2a9 100644
--- a/assets/creator-studio/src/utils/serialize.ts
+++ b/assets/creator-studio/src/utils/serialize.ts
@@ -2,9 +2,14 @@
* 编辑器内容 ↔ 保存格式的转换。
*
* 编辑器里把公式、注释渲染成可读的样子(方便写作时看到最终效果),
- * 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容与代码块本身就是
+ * 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容本身就是
* 短代码 / 前台结构,不需要转换。
*
+ * 代码块例外:前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,认的是
+ * `pre > code`,并把源码里的换行当作分行;而 TinyMCE 把 `pre > code` 当成
+ * 行内边界,光标落不进去、输入会被拆成段落。因此编辑器里用裸 `` 编辑,
+ * 保存前套回 `pre > code` 并把 `
` 还原成换行符。
+ *
* @package Jio
*/
@@ -17,17 +22,104 @@ const decodeEntities = (value: string): string => {
return area.value;
};
+/**
+ * 读出代码块的源码行。
+ *
+ * 两种形态都要认:编辑器里的裸 ``(换行是 `
`),以及前台落库的
+ * `pre > code`(换行是文本里的 `\n`)。`` 只是容器,直接走进去读,
+ * 不重复计数。
+ */
+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 收尾的
、落库时补的换行符)。
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
+ lines.pop();
+ }
+ return lines;
+};
+
+/**
+ * 编辑器里的裸 `` → 前台的 `pre > code`。
+ *
+ * `
` 与文本里的换行都换成分行符:Enlighter 直接按文本切行,留着 `
`
+ * 会把整块压成一行、长行溢出卡片。
+ */
+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` → 编辑器里可直接输入的裸 ``。
+ */
+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('[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) =>
diff --git a/assets/css/frontend/pages/submit-editor.css b/assets/css/frontend/pages/submit-editor.css
index 49aec683..302971e7 100644
--- a/assets/css/frontend/pages/submit-editor.css
+++ b/assets/css/frontend/pages/submit-editor.css
@@ -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 撑满编辑区:内容很短时点击正文下方的空白区域也应落光标,
* 否则只有文字所在的那一行能点。
diff --git a/tests/widget-system/creator-studio-contract.test.cjs b/tests/widget-system/creator-studio-contract.test.cjs
index 2158028e..fe3fb277 100644
--- a/tests/widget-system/creator-studio-contract.test.cjs
+++ b/tests/widget-system/creator-studio-contract.test.cjs
@@ -207,14 +207,16 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
// 表格 / 公式 / 注释 / 代码都走参数弹窗,不再是固定内容。
assert.match(appSource, / code,不写语言 / 主题属性。
- assert.match(markup, /<\/code><\/pre>/);
+ // 代码块:编辑器里是裸 pre(TinyMCE 把 pre > code 当行内边界,光标落不进去),
+ // 保存时才由 serializeBlocks 套回前台的 pre > code。不写语言 / 主题属性。
+ assert.match(markup, /
<\/pre><\/p>/);
+ assert.doesNotMatch(markup, /
<\/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/);