diff --git a/assets/creator-studio/src/editor-content-style.ts b/assets/creator-studio/src/editor-content-style.ts index 2a40e3af..5df5c8df 100644 --- a/assets/creator-studio/src/editor-content-style.ts +++ b/assets/creator-studio/src/editor-content-style.ts @@ -2,8 +2,7 @@ * TinyMCE iframe 内的正文样式。 * * 编辑器正文跑在 iframe 里,页面级 SCSS 影响不到它,所以这里显式给出: - * 正文排版、主题引用 .quote_q、隐藏内容 .tinymce-hide、表格、公式、 - * 块级删除控件与块选中态。 + * 正文排版、主题引用 .quote_q、隐藏内容 .tinymce-hide、表格、公式。 * * @package Jio */ @@ -53,66 +52,6 @@ h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.65em; line-height: 1.4; } img, iframe { max-width: 100%; } img { height: auto; } -/* - * 块级删除控件。它由 editor-runtime 插进 iframe 的 body,样式必须写在这里: - * 父页面样式表管不到 iframe 内部,缺这条规则时按钮会退化成正文顶部的常显小方块。 - * 正文是流式布局且 iframe 高度随内容增长,所以按整篇正文定位,而不是相对某个块。 - */ -#jio-block-actions { - position: absolute; - z-index: 5; - top: 6px; - left: 6px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; -} -#jio-block-actions.is-visible { - opacity: 1; - pointer-events: auto; -} -#jio-block-actions .jio-creator__block-action { - display: inline-flex; - height: 26px; - align-items: center; - gap: 5px; - padding: 0 9px; - border: 1px solid #e3e7e5; - border-radius: 6px; - background: #fff; - color: #7d8683; - font: inherit; - font-size: 12px; - line-height: 1; - white-space: nowrap; - box-shadow: 0 2px 8px rgba(24, 39, 34, 0.08); - cursor: pointer; -} -#jio-block-actions .jio-creator__block-action-icon { - font-size: 11px; - line-height: 1; -} -#jio-block-actions .jio-creator__block-action:hover { - border-color: #cdd6d2; - color: #4f5957; -} -#jio-block-actions.is-selected .jio-creator__block-action { - border-color: #e0a99b; - background: #fdf2ee; - color: #b4573f; -} -#jio-block-actions.is-selected .jio-creator__block-action:hover { - border-color: #cf8574; - background: #fbe7e1; -} - -/* 第一次点击选中的块:虚线高亮,明确「还没删」。 */ -.jio-creator__block-selected { - outline: 2px dashed rgba(180, 87, 63, 0.55); - outline-offset: 3px; - border-radius: 4px; -} - /* * 代码块:与前台文章页一致。 * diff --git a/assets/creator-studio/src/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts index 9b2caecc..26156d09 100644 --- a/assets/creator-studio/src/editor-runtime.ts +++ b/assets/creator-studio/src/editor-runtime.ts @@ -1,14 +1,11 @@ /** - * TinyMCE 编辑器增强:块级两步删除、自适应高度。 + * TinyMCE 编辑器增强:块级删除键、自适应高度。 * * 这些行为都作用在编辑器 iframe 内部,与 React 状态无关,因此独立成模块。 * * @package Jio */ -/** 可整体删除的块级元素选择器。 */ -const BLOCK_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,p'; - /** 结构块(不能从内部被拆开的块)。 */ const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,.jio-math--block'; @@ -123,7 +120,7 @@ export function syncEditorHeight(editor: any): void { * 变高。这里在 iframe 内监听资源加载与尺寸变化,加载完成后重新同步一次, * 否则图片下方的段落会被 iframe 的固定高度裁掉。 * - * 只在 body 上挂一次(installBlockActions 同一条 init 路径),避免重复监听。 + * 只在 body 上挂一次(与高度同步共用同一条 init 路径),避免重复监听。 */ export function installHeightWatchers(editor: any): void { const doc = editor?.getDoc?.(); @@ -150,186 +147,3 @@ export function installHeightWatchers(editor: any): void { new (window as any).ResizeObserver(() => syncEditorHeight(editor)).observe(doc.body); } } - -/** 块选中状态写在块自身的 class 上,跨 iframe 重排也能保留。 */ -const SELECTED_CLASS = 'jio-creator__block-selected'; - -/** - * 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。 - * - * 与 WPS 一致的两步删除:第一次点击只是选中该块并高亮,第二次点击才真正删除。 - * 选中态下按钮改为醒目的「再次点击删除」,且鼠标移开不会消失,避免误删。 - * - * 浮层挂在 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(); - if (!doc || doc.getElementById('jio-block-actions')) { - return; - } - const bar = doc.createElement('div'); - bar.id = 'jio-block-actions'; - bar.className = 'jio-creator__block-actions'; - // "all" 让 TinyMCE 连同子节点一起跳过。 - bar.setAttribute('data-mce-bogus', 'all'); - bar.innerHTML = - ''; - 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'); - let hovered: HTMLElement | null = null; - let selected: HTMLElement | null = null; - - /** 底部状态栏以外的提示文字。 */ - const labelFor = (state: 'idle' | 'selected'): string => - state === 'selected' ? '再次点击删除此块' : '选中此块'; - - const paint = (state: 'idle' | 'selected'): void => { - bar.classList.toggle('is-selected', state === 'selected'); - if (text) { - text.textContent = labelFor(state); - } - const label = labelFor(state); - button?.setAttribute('title', label); - button?.setAttribute('aria-label', label); - }; - - const place = (block: HTMLElement): void => { - const rect = block.getBoundingClientRect(); - // 右上角贴住块,但不越出正文左边界(窄块时按钮会溢出)。 - const left = Math.max(rect.left + 8, rect.right - 96); - bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`; - bar.style.left = `${left + doc.documentElement.scrollLeft}px`; - }; - - const show = (block: HTMLElement | null): void => { - if (!block || block === doc.body) { - bar.classList.remove('is-visible'); - return; - } - // 空段落没有删除价值,交给退格键处理。 - if (block.tagName === 'P' && (block.textContent || '').trim() === '' && !block.querySelector('img,iframe,table,pre')) { - bar.classList.remove('is-visible'); - return; - } - hovered = block; - place(block); - bar.classList.add('is-visible'); - }; - - const deselect = (): void => { - if (selected) { - selected.classList.remove(SELECTED_CLASS); - } - selected = null; - paint('idle'); - if (hovered) { - show(hovered); - } else { - bar.classList.remove('is-visible'); - } - }; - - doc.addEventListener('mouseover', (event: MouseEvent) => { - const block = (event.target as HTMLElement)?.closest?.(BLOCK_SELECTOR) as HTMLElement | null; - if (!block || block === doc.body) { - return; - } - // 选中块时保持浮层贴在选中块上,不受悬停目标影响。 - if (selected) { - return; - } - show(block); - }); - - doc.addEventListener('mouseleave', () => { - hovered = null; - if (selected) { - place(selected); - return; - } - bar.classList.remove('is-visible'); - }); - - bar.addEventListener('mousedown', (event: MouseEvent) => event.preventDefault()); - const onClick = (event: MouseEvent): void => { - const target = (event.target as HTMLElement)?.closest('[data-jio-block-delete]'); - const block = selected || hovered; - if (!target || !block) { - return; - } - event.preventDefault(); - - if (selected !== block) { - // 第一次点击:只选中,不删除。 - if (selected) { - selected.classList.remove(SELECTED_CLASS); - } - selected = block; - block.classList.add(SELECTED_CLASS); - paint('selected'); - place(block); - bar.classList.add('is-visible'); - return; - } - - // 第二次点击:真正删除。 - const victim = block; - const parent = victim.parentElement; - victim.classList.remove(SELECTED_CLASS); - selected = null; - hovered = null; - bar.classList.remove('is-visible', 'is-selected'); - paint('idle'); - editor.undoManager.transact(() => { - editor.dom.remove(victim); - }); - if (editor.getBody().children.length === 0) { - editor.setContent('
'); - } - if (parent && parent.children.length === 0) { - editor.setContent(''); - } - const first = editor.getBody().firstChild; - if (first) { - editor.selection.setCursorLocation(first, 0); - } - editor.nodeChanged(); - editor.undoManager.add(); - syncEditorHeight(editor); - }; - bar.addEventListener('click', onClick); - - // 点击正文其它位置或按 Esc 取消选中态。 - doc.addEventListener('mousedown', (event: MouseEvent) => { - if (bar.contains(event.target as Node)) { - return; - } - if (selected) { - deselect(); - } - }); - doc.addEventListener('keydown', (event: KeyboardEvent) => { - if (event.key === 'Escape' && selected) { - deselect(); - } - }); - - // 内容被撤销 / 重排后,被选中的块可能已不在文档里。 - editor.on('Undo Redo SetContent', () => { - if (selected && !doc.body.contains(selected)) { - selected = null; - paint('idle'); - bar.classList.remove('is-visible', 'is-selected'); - } - }); -} diff --git a/assets/creator-studio/src/index.tsx b/assets/creator-studio/src/index.tsx index 56679c64..84804222 100644 --- a/assets/creator-studio/src/index.tsx +++ b/assets/creator-studio/src/index.tsx @@ -38,7 +38,6 @@ import { import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog'; import { type Draft, type Limits, initialDraft } from './draft'; import { - installBlockActions, installHeightWatchers, removeAdjacentBlock, syncEditorHeight, @@ -445,7 +444,6 @@ function Studio(): JSX.Element { editor.setContent(hydrateBlocks(editor.getContent())); } syncEditorHeight(editor); - installBlockActions(editor); installHeightWatchers(editor); updateHistoryAvailability(editor); updateFormatValue(editor); diff --git a/assets/js/front-submit-editor-basics.js b/assets/js/front-submit-editor-basics.js index 951b0437..af3d2d03 100644 --- a/assets/js/front-submit-editor-basics.js +++ b/assets/js/front-submit-editor-basics.js @@ -48,9 +48,8 @@ /** * 正文自身的副本:剔除 TinyMCE 的辅助节点。 * - * 编辑器会把块级删除浮层(#jio-block-actions)之类带文字的工具节点插进 - * body,它们都带 data-mce-bogus。直接读 body.textContent 会把「选中此块」 - * 当成正文,导致空正文永远判成非空、占位符不显示。 + * 编辑器会把带文字的工具节点插进 body,它们都带 data-mce-bogus。直接读 + * body.textContent 会把工具文案当成正文,导致空正文永远判成非空、占位符不显示。 */ function contentClone(body) { var clone = body.cloneNode(true); diff --git a/tests/widget-system/creator-studio-contract.test.cjs b/tests/widget-system/creator-studio-contract.test.cjs index 23225aab..27b9c13c 100644 --- a/tests/widget-system/creator-studio-contract.test.cjs +++ b/tests/widget-system/creator-studio-contract.test.cjs @@ -312,38 +312,25 @@ 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 outside the editable body and stays hidden', () => { +// 块右上角的悬停删除浮层已移除:删除统一走键盘(退格 / Delete)与工具栏。 +// 浮层留在这里会被误当成「不需要的关闭按钮」,也曾在 body 里破坏 mceInsertContent。 +test('hover block-delete overlay is gone', () => { 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'); + const app = read('assets/creator-studio/src/index.tsx'); - // 浮层挂在 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/); - assert.match(contentStyle, /#jio-block-actions\s*\{[^}]*opacity:\s*0/); - assert.match(contentStyle, /#jio-block-actions\.is-visible\s*\{[^}]*opacity:\s*1/); - assert.match(contentStyle, /body\s*\{[^}]*position:\s*relative/); - // 页面级 SCSS 不再重复定义 iframe 内部的选择器(否则只是死样式)。 - assert.doesNotMatch(pageStyles, /\.jio-creator__block-actions\s*\{/); - // data-mce-bogus 让 TinyMCE 序列化、字数和选区都跳过这个浮层。 - assert.match(runtime, /setAttribute\('data-mce-bogus', 'all'\)/); + assert.doesNotMatch(runtime, /jio-block-actions/); + assert.doesNotMatch(runtime, /jio-creator__block-actions/); + assert.doesNotMatch(runtime, /installBlockActions/); + assert.doesNotMatch(runtime, /BLOCK_SELECTOR/); + assert.doesNotMatch(contentStyle, /jio-block-actions/); + assert.doesNotMatch(contentStyle, /jio-creator__block-selected/); + assert.doesNotMatch(app, /installBlockActions/); - // 两步删除:第一次点击只选中,第二次才真删;不再有关闭 / 叉号语义。 - const firstClick = runtime.indexOf('if (selected !== block) {'); - const secondClick = runtime.indexOf('// 第二次点击:真正删除。'); - assert.ok(firstClick !== -1 && secondClick !== -1, 'two-step delete branches both exist'); - assert.ok(firstClick < secondClick, 'first click only selects'); - assert.ok( - runtime.indexOf('block.classList.add(SELECTED_CLASS)', firstClick) < secondClick, - 'selection class is applied on the first click' - ); - assert.match(contentStyle, /\.jio-creator__block-selected\s*\{/); - assert.match(runtime, /再次点击删除此块/); + // 键盘删除仍然保留:空段落旁的结构块要能被退格删掉。 + assert.match(runtime, /export function removeAdjacentBlock/); + assert.match(runtime, /STRUCTURAL_SELECTOR/); + assert.match(app, /removeAdjacentBlock\(editor, event\)/); }); // 工具栏下拉:菜单必须 portal 到 body,否则会被横向滚动的工具条裁剪。 diff --git a/tests/widget-system/submit-placeholder-behavior.test.cjs b/tests/widget-system/submit-placeholder-behavior.test.cjs index 64581e32..13509554 100644 --- a/tests/widget-system/submit-placeholder-behavior.test.cjs +++ b/tests/widget-system/submit-placeholder-behavior.test.cjs @@ -140,13 +140,13 @@ test('placeholder never becomes a DOM node in the editable body', () => { }); test('editor chrome injected into the body does not count as content', () => { - // 真实页面上 editor-runtime 会把块删除浮层插进 body,它带 data-mce-bogus - // 但含文字(「✕ 选中此块」)。若按 body.textContent 判断,空正文永远非空, - // 占位符就永远不显示 —— 这正是「首次加载不显示」的真实原因。 + // TinyMCE 会往 body 里插带 data-mce-bogus 的工具节点,其中可能含文字。 + // 若按 body.textContent 判断,空正文永远非空,占位符就永远不显示 —— + // 这正是「首次加载不显示」的真实原因。 const { doc, editor } = createEditor( '