From 4f9347b6aad6df611ea61cc61e0bfaccdc08d619 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 9 Nov 2026 21:37:14 +0800 Subject: [PATCH] fix(submit): let the editor iframe grow with the body instead of scrolling internally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正文超过 iframe 高度时会在编辑器内部出现第二根滚动条,正文应该随页面一起变长。 登录态实测确认了滚动来源与两个副作用: 1. 经典投稿表单完全没做高度同步(iframe 固定 504px,正文 1824px → 内滚动)。 在共用基础插件里补上 syncHeight():测量正文高度并同步 iframe, 下限取编辑器声明高度;图片加载后重新同步。工作台自带 syncEditorHeight() 负责块浮层定位,用 __jioStudioOwnsEditorHeight 让插件跳过,避免双写。 2. 直接量 scrollHeight 会把 body 的 min-height 撑满算进内容高度,形成 「撑高 → 再撑高」的反馈循环(实测涨到 22616px)。测量时临时把 min-height 归零、同帧恢复。 3. body 的 min-height 基准从 100% 改成 100vh:iframe 随正文变高, 百分比会跟着循环;同时把 WordPress content CSS 的 body 外边距改内边距, 外边距不计入高度,会让 html 比 iframe 高一截、长正文时又冒出内滚动。 登录态实测(studio 与经典两条路径): - iframe 2152px / 1874px,与正文一致,innerHasScrollbar=false; - 空正文占位符正常、点击空白落光标正常; - 只有页面级滚动。 测试:新增 iframe 随正文增长、测量忽略 min-height 两个回归用例;903 全通过。 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/editor-content-style.ts | 25 +++----- assets/creator-studio/src/editor-runtime.ts | 25 +++++--- assets/creator-studio/src/index.tsx | 5 ++ assets/css/frontend/pages/submit-editor.css | 16 ++--- assets/js/front-submit-editor-basics.js | 59 +++++++++++++++++- .../creator-studio-contract.test.cjs | 10 +-- .../submit-placeholder-behavior.test.cjs | 62 +++++++++++++++++++ 7 files changed, 164 insertions(+), 38 deletions(-) diff --git a/assets/creator-studio/src/editor-content-style.ts b/assets/creator-studio/src/editor-content-style.ts index e3b32b90..6348ea7b 100644 --- a/assets/creator-studio/src/editor-content-style.ts +++ b/assets/creator-studio/src/editor-content-style.ts @@ -9,8 +9,17 @@ */ export const EDITOR_CONTENT_STYLE = ` +/* + * 正文撑满编辑区:内容很短时点击正文下方的空白区域也应落光标。 + * iframe 高度由 syncEditorHeight() 跟随内容。 + * + * 高度基准取 iframe 视口(100vh 而不是 100%):iframe 会随正文同步变高, + * 用百分比会形成「撑高 → 再撑高」的循环。100vh 只保证空正文铺满一屏。 + */ body { position: relative; + box-sizing: border-box; + min-height: 100vh; margin: 0; color: #2b2b2b; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; @@ -36,22 +45,6 @@ body[data-jio-empty="1"]::before { user-select: none; } -/* - * body 撑满编辑区:内容很短时点击正文下方的空白区域也应落光标。 - * iframe 高度由 syncEditorHeight() 跟随内容,这里保证 body 至少铺满。 - * - * min-height:100% 依赖父级有确定高度,所以 html 必须 height:100% - * (只写 min-height 的话 html 的高度仍由内容决定,百分比会退化成 auto)。 - */ -html { - height: 100%; -} - -body { - box-sizing: border-box; - min-height: 100%; -} - 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/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts index 25a5780c..4be834c1 100644 --- a/assets/creator-studio/src/editor-runtime.ts +++ b/assets/creator-studio/src/editor-runtime.ts @@ -89,8 +89,10 @@ export function removeAdjacentBlock(editor: any, event: any): void { * 页面整体滚动由外层工作台负责。 * * 下限取编辑器初始化时声明的高度(studio 为 520),这样正文很短时 iframe - * 也保持一个完整写作区,空白处由 content CSS 的 body min-height 撑满, - * 点击正文下方仍能落光标。 + * 也保持一个完整写作区,空白处由 content CSS 的 body min-height 撑满。 + * + * 测量时必须先摘掉 body 的 min-height:它按 iframe 视口撑满,直接量 + * scrollHeight 会得到「撑高 → 再撑高」的反馈循环。 */ export function syncEditorHeight(editor: any): void { if (!editor || editor.removed) { @@ -98,17 +100,22 @@ export function syncEditorHeight(editor: any): void { } const doc = editor.getDoc(); const iframe = editor.getContentAreaContainer()?.querySelector('iframe') as HTMLIFrameElement | null; - if (!doc || !iframe) { + if (!doc || !doc.body || !iframe) { return; } + const body = doc.body; + const prevMinHeight = body.style.minHeight; + body.style.minHeight = '0'; + const contentHeight = Math.max(body.scrollHeight, body.offsetHeight); + body.style.minHeight = prevMinHeight; + const floor = Math.max(320, Number(editor.getParam?.('height', 0)) || 0); - const height = Math.max( - floor, - doc.documentElement.scrollHeight, - doc.body ? doc.body.scrollHeight : 0 - ); - iframe.style.height = `${height}px`; + const height = Math.max(floor, contentHeight); + if (iframe.style.height !== `${height}px`) { + iframe.style.height = `${height}px`; + } iframe.style.overflow = 'hidden'; + iframe.setAttribute('scrolling', 'no'); } /** 块选中状态写在块自身的 class 上,跨 iframe 重排也能保留。 */ diff --git a/assets/creator-studio/src/index.tsx b/assets/creator-studio/src/index.tsx index 4a2ae795..9e5242eb 100644 --- a/assets/creator-studio/src/index.tsx +++ b/assets/creator-studio/src/index.tsx @@ -380,6 +380,8 @@ function Studio(): JSX.Element { external_plugins: bootstrap.creator?.editorBasicsPlugin ? { jio_editor_basics: bootstrap.creator.editorBasicsPlugin } : undefined, + // 高度由 editor-runtime 的 syncEditorHeight() 接管(还要配合块浮层定位), + // 基础插件据此跳过自己的高度同步。 wpautop: true, body_class: 'jio-creator__tinymce', content_css: bootstrap.creator?.contentCss || '', @@ -388,6 +390,8 @@ function Studio(): JSX.Element { height: 520, placeholder: String(bootstrap.limits?.contentPlaceholder || ''), setup: (editor: any) => { + // 告诉共用基础插件:高度由 syncEditorHeight() 负责。 + (window as any).__jioStudioOwnsEditorHeight = true; editorRef.current = editor; editor.on('init', () => { editorReadyRef.current = true; @@ -430,6 +434,7 @@ function Studio(): JSX.Element { wpEditor.remove?.(EDITOR_ID); editorReadyRef.current = false; editorRef.current = null; + delete (window as any).__jioStudioOwnsEditorHeight; }; }, [updateContent, updateBlockValue, updateFormatValue]); diff --git a/assets/css/frontend/pages/submit-editor.css b/assets/css/frontend/pages/submit-editor.css index 5eb1a768..49aec683 100644 --- a/assets/css/frontend/pages/submit-editor.css +++ b/assets/css/frontend/pages/submit-editor.css @@ -228,15 +228,17 @@ body[data-jio-empty="1"]::before { * body 撑满编辑区:内容很短时点击正文下方的空白区域也应落光标, * 否则只有文字所在的那一行能点。 * - * min-height:100% 依赖父级有确定高度,所以 html 必须 height:100%(只写 - * min-height 的话 html 的高度仍由内容决定,百分比会退化成 auto)。 + * 高度基准取 iframe 视口(100vh 而不是 100%):iframe 由插件随正文同步变高, + * 用百分比会形成「撑高 → 再撑高」的循环。100vh 只保证空正文铺满一屏。 + * + * 外边距改内边距:WordPress 的 content CSS 给 body 留了 9px 外边距, + * 外边距不计入 body 高度,会让 html 的 scrollHeight 永远比 iframe 多出一截, + * 长正文时又冒出内滚动条。 */ -html { - height: 100%; -} - body.mce-content-body { position: relative; box-sizing: border-box; - min-height: 100%; + min-height: 100vh; + margin: 0; + padding: 9px 10px; } diff --git a/assets/js/front-submit-editor-basics.js b/assets/js/front-submit-editor-basics.js index c2fa2d81..951b0437 100644 --- a/assets/js/front-submit-editor-basics.js +++ b/assets/js/front-submit-editor-basics.js @@ -15,6 +15,11 @@ * 能把光标移回正文;正文以表格、代码块这类结构块结尾时更是无处可落。这里 * 在点击空白处时补一个可编辑尾段并把光标放进去。 * + * 3) 高度跟随正文 + * TinyMCE 的 iframe 是固定高度,正文超过它就在编辑器内部出现滚动条——正文 + * 应该随页面一起变长,而不是套一层内滚动。这里测量正文高度并同步 iframe, + * 下限取编辑器声明的高度(写作区不会缩成一行)。 + * * 视觉样式写在 content CSS(submit-editor.css / editor-content-style.ts)—— * 编辑器正文在 iframe 里,页面样式管不到。 * @@ -127,11 +132,63 @@ editor.selection.setCursorLocation(home, home.childNodes.length); } + /** + * 让 iframe 高度跟随正文增长,避免编辑器内部出现第二根滚动条。 + * + * 下限取编辑器声明的高度:正文很短时写作区也不会缩成一行。 + * + * 创作者工作台自带一套高度同步(editor-runtime.ts 的 syncEditorHeight, + * 还负责块浮层定位),那边由它接管,这里不重复计算。 + */ + function syncHeight() { + if (window.__jioStudioOwnsEditorHeight) { + return; + } + var body = root(); + var doc = editor.getDoc(); + if (!body || !doc) { + return; + } + var container = editor.getContentAreaContainer && editor.getContentAreaContainer(); + var iframe = container ? container.querySelector('iframe') : null; + if (!iframe) { + return; + } + // body 的 min-height 让空正文铺满 iframe;直接量 scrollHeight 会把它算进 + // 内容高度,得到「撑高 → 再撑高」的反馈循环(实测会涨到两万多 px)。 + // 测量时临时归零,同一帧内恢复,不会产生可见闪烁。 + var prevMinHeight = body.style.minHeight; + body.style.minHeight = '0'; + var contentHeight = Math.max(body.scrollHeight || 0, body.offsetHeight || 0); + body.style.minHeight = prevMinHeight; + + var floor = Math.max(320, Number(editor.getParam('height', 0)) || 0); + var height = Math.max(floor, contentHeight); + // 只在变化时写回,避免每次按键都触发一次布局。 + if (iframe.style.height !== height + 'px') { + iframe.style.height = height + 'px'; + } + iframe.style.overflow = 'hidden'; + iframe.setAttribute('scrolling', 'no'); + } + // PreInit 时 body 可能已就绪:先打上属性,避免首帧闪一下空白再出现提示。 editor.on('PreInit init', syncPlaceholder); editor.on('SetContent input keyup change Undo Redo NodeChange', function () { // SetContent 之后 DOM 才稳定,放到下一帧再判断。 - window.setTimeout(syncPlaceholder, 0); + window.setTimeout(function () { + syncPlaceholder(); + syncHeight(); + }, 0); + }); + + // 图片 / 视频加载完成后正文高度会变,需要重新同步。 + editor.on('init', function () { + var body = root(); + if (body) { + body.addEventListener('load', syncHeight, true); + } + syncHeight(); }); editor.on('click', restoreCaret); diff --git a/tests/widget-system/creator-studio-contract.test.cjs b/tests/widget-system/creator-studio-contract.test.cjs index e0b26208..2158028e 100644 --- a/tests/widget-system/creator-studio-contract.test.cjs +++ b/tests/widget-system/creator-studio-contract.test.cjs @@ -412,11 +412,11 @@ test('submit editor renders the configured body placeholder', () => { assert.match(styles, /body\[data-jio-empty="1"\]::before\s*\{/); assert.match(contentStyle, /body\[data-jio-empty="1"\]::before\s*\{/); // 两份 content CSS 都要让 body 撑满,否则正文下方的空白不属于 iframe。 - // min-height:100% 依赖父级确定高度,html 必须 height:100%。 - assert.match(styles, /html\s*\{[^}]*height:\s*100%/); - assert.match(styles, /body\.mce-content-body\s*\{[^}]*min-height:\s*100%/); - assert.match(contentStyle, /html\s*\{[^}]*height:\s*100%/); - assert.match(contentStyle, /body\s*\{[^}]*min-height:\s*100%/); + // 用 100vh 而不是 100%:iframe 随正文变高,百分比会形成撑高循环。 + assert.match(styles, /body\.mce-content-body\s*\{[^}]*min-height:\s*100vh/); + assert.match(contentStyle, /body\s*\{[^}]*min-height:\s*100vh/); + // 外边距不计入 body 高度,会让 html 比 iframe 高一截、长正文时出现内滚动。 + assert.match(styles, /body\.mce-content-body\s*\{[^}]*margin:\s*0/); // 空正文时 iframe 不能缩到只剩一行:高度下限取编辑器声明的高度。 assert.match(runtime, /getParam\?\.\('height', 0\)/); diff --git a/tests/widget-system/submit-placeholder-behavior.test.cjs b/tests/widget-system/submit-placeholder-behavior.test.cjs index 1d1e85b6..64581e32 100644 --- a/tests/widget-system/submit-placeholder-behavior.test.cjs +++ b/tests/widget-system/submit-placeholder-behavior.test.cjs @@ -156,6 +156,68 @@ test('editor chrome injected into the body does not count as content', () => { assert.equal(doc.body.getAttribute('data-jio-empty'), '1', 'bogus chrome must not count as content'); }); +test('editor iframe grows with the content instead of scrolling internally', () => { + const { doc, editor } = createEditor('

短正文

', { height: 520, placeholder: '请输入正文' }); + + // 极简 iframe 替身:插件只读写 style.height / overflow / scrolling。 + const iframe = doc.createElement('iframe'); + const container = doc.createElement('div'); + container.appendChild(iframe); + editor.getContentAreaContainer = () => container; + + const plugin = loadPlugin()[PLUGIN]; + plugin(editor); + editor.fire('init'); + + const initial = iframe.style.height; + assert.equal(initial, '520px', 'short content keeps the declared writing area'); + + // 正文变长:scrollHeight 变大,iframe 必须跟着长。 + Object.defineProperty(doc.body, 'scrollHeight', { configurable: true, get: () => 2400 }); + Object.defineProperty(doc.body, 'offsetHeight', { configurable: true, get: () => 2400 }); + editor.fire('input'); + + assert.equal(iframe.style.height, '2400px', 'iframe height must follow the content'); + assert.equal(iframe.style.overflow, 'hidden'); + assert.equal(iframe.getAttribute('scrolling'), 'no'); +}); + +test('height measurement ignores the body min-height fill', () => { + // body 的 min-height 让空正文铺满视口;如果测量时也带上它, + // 就会得到「撑高 → 再撑高」的反馈循环(实测会涨到两万多 px)。 + const { doc, editor } = createEditor('

正文

', { height: 520 }); + const iframe = doc.createElement('iframe'); + const container = doc.createElement('div'); + container.appendChild(iframe); + editor.getContentAreaContainer = () => container; + + // 模拟「body 被 min-height 撑到 5000」但真实内容只有 1800。 + let minHeightApplied = false; + Object.defineProperty(doc.body, 'scrollHeight', { + configurable: true, + get: () => (minHeightApplied ? 5000 : 1800), + }); + Object.defineProperty(doc.body, 'offsetHeight', { + configurable: true, + get: () => (minHeightApplied ? 5000 : 1800), + }); + // 插件把 min-height 归零时视为摘掉撑满。 + Object.defineProperty(doc.body.style, 'minHeight', { + configurable: true, + get: () => (minHeightApplied ? '100vh' : ''), + set: (value) => { + minHeightApplied = value !== '0'; + }, + }); + minHeightApplied = true; + + const plugin = loadPlugin()[PLUGIN]; + plugin(editor); + editor.fire('init'); + + assert.equal(iframe.style.height, '1800px', 'measure the content, not the viewport fill'); +}); + test('no placeholder configured leaves the body untouched', () => { const { doc, editor } = createEditor('


', {}); const plugin = loadPlugin()[PLUGIN];