diff --git a/packages/renderless/src/fluent-editor/index.ts b/packages/renderless/src/fluent-editor/index.ts index 4496c4d0e7..0ff92f011c 100644 --- a/packages/renderless/src/fluent-editor/index.ts +++ b/packages/renderless/src/fluent-editor/index.ts @@ -126,6 +126,7 @@ export const init = } api.handleComposition() + api.bindTableToolsScroll() emit('ready', state.quill) } @@ -936,6 +937,7 @@ export const beforeUnmount = fullscreenButton && (fullscreenButton.onclick = null) api.removeFullscreenchange() api.removeHandleComposition() + api.unbindTableToolsScroll() state.quill.off('selection-change', api.selectionChange) state.quill.off('text-change', api.textChange) off(state.quill.container, 'click', state.linkClickHandler, true) @@ -1099,11 +1101,11 @@ const getToolbarTitle = (t) => { { selector: '.ql-color', title: t('ui.richText.color') }, { selector: '.ql-background', title: t('ui.richText.background') }, { selector: '.ql-align.ql-picker', title: t('ui.richText.align') }, - { selector: '.ql-align[value=""]', title: t('ui.richText.alignPicker1') }, + { selector: 'button.ql-align[value=""]', title: t('ui.richText.alignPicker1') }, { selector: 'button.ql-align:not([value])', title: t('ui.richText.alignPicker1') }, - { selector: '.ql-align[value="center"]', title: t('ui.richText.alignPicker2') }, - { selector: '.ql-align[value="right"]', title: t('ui.richText.alignPicker3') }, - { selector: '.ql-align[value="justify"]', title: t('ui.richText.alignPicker4') }, + { selector: 'button.ql-align[value="center"]', title: t('ui.richText.alignPicker2') }, + { selector: 'button.ql-align[value="right"]', title: t('ui.richText.alignPicker3') }, + { selector: 'button.ql-align[value="justify"]', title: t('ui.richText.alignPicker4') }, { selector: '.ql-align .ql-picker-item:not([data-value])', title: t('ui.richText.alignPicker1') }, { selector: '.ql-align .ql-picker-item[data-value="center"]', title: t('ui.richText.alignPicker2') }, { selector: '.ql-align .ql-picker-item[data-value="right"]', title: t('ui.richText.alignPicker3') }, @@ -1130,6 +1132,113 @@ export const setToolbarTitle = } } +const TABLE_CONTROL_TOP_OFFSET = 25 // 与 @opentiny/fluent-editor TableColumnTool.initColTool 的 top 偏移一致 +const TABLE_SCROLL_BAR_PANEL_HEIGHT = 32 // 与 fluent-editor SCROLL_BAR_PANEL_HEIGHT 一致 + +const syncTableScrollBarPosition = ({ parent, containerRect, table, tableScrollBar }) => { + const scrollBar = tableScrollBar?.domNode + if (!scrollBar) { + return + } + + const tableRect = table.getBoundingClientRect() + const parentScrollTop = parent.scrollTop || 0 + const containerHeight = parent.clientHeight || containerRect.height + const tableVisualTop = tableRect.top - containerRect.top + const tableVisualBottom = tableRect.bottom - containerRect.top + + if (tableVisualBottom <= 0 || tableVisualTop >= containerHeight) { + scrollBar.style.display = 'none' + return + } + + const barPos = tableVisualBottom + parentScrollTop - 1 + const pinPos = parentScrollTop + containerHeight - TABLE_SCROLL_BAR_PANEL_HEIGHT + + scrollBar.style.display = 'block' + scrollBar.style.marginTop = '0px' + scrollBar.style.top = `${Math.min(pinPos, barPos)}px` +} + +export const syncTableControlPanelPosition = (quill) => { + const parent = quill?.root?.parentNode + if (!parent) { + return + } + + const betterTable = quill.getModule?.('better-table') + if (!betterTable) { + return + } + + const columnTool = betterTable.columnTool + const table = columnTool?.table || betterTable.tableScrollBar?.table + const containerRect = parent.getBoundingClientRect() + + if (table?.parentNode && columnTool?.domNode) { + const tableViewRect = table.parentNode.getBoundingClientRect() + columnTool.domNode.style.marginTop = '0px' + columnTool.domNode.style.top = `${tableViewRect.top - containerRect.top + parent.scrollTop - TABLE_CONTROL_TOP_OFFSET}px` + } + + if (table) { + syncTableScrollBarPosition({ parent, containerRect, table, tableScrollBar: betterTable.tableScrollBar }) + } + + if (betterTable.tableSelection?.selectedTds?.length) { + betterTable.tableSelection.refreshHelpLinesPosition?.() + } +} + +export const bindTableToolsScroll = + ({ state, api }) => + () => { + const root = state.quill?.root + if (!root) { + return + } + + api.unbindTableToolsScroll() + + state.tableToolsScrollHandler = () => { + const betterTable = state.quill?.getModule?.('better-table') + if (!betterTable?.columnTool && !betterTable?.tableScrollBar) { + return + } + if (state.tableToolsScrollRaf) { + cancelAnimationFrame(state.tableToolsScrollRaf) + } + state.tableToolsScrollRaf = requestAnimationFrame(() => { + state.tableToolsScrollRaf = null + syncTableControlPanelPosition(state.quill) + }) + } + + const parent = root.parentNode + state.tableToolsScrollTargets = parent ? [root, parent] : [root] + state.tableToolsScrollTargets.forEach((target) => { + on(target, 'scroll', state.tableToolsScrollHandler) + }) + } + +export const unbindTableToolsScroll = + ({ state }) => + () => { + if (state.tableToolsScrollRaf) { + cancelAnimationFrame(state.tableToolsScrollRaf) + state.tableToolsScrollRaf = null + } + + if (state.tableToolsScrollHandler && state.tableToolsScrollTargets) { + state.tableToolsScrollTargets.forEach((target) => { + off(target, 'scroll', state.tableToolsScrollHandler) + }) + } + + state.tableToolsScrollHandler = null + state.tableToolsScrollTargets = null + } + export const computeZIndex = ({ constants, props }) => () => diff --git a/packages/renderless/src/fluent-editor/vue.ts b/packages/renderless/src/fluent-editor/vue.ts index f813e11e52..1eb712325d 100644 --- a/packages/renderless/src/fluent-editor/vue.ts +++ b/packages/renderless/src/fluent-editor/vue.ts @@ -37,7 +37,9 @@ import { removeHandleComposition, checkTableISEndElement, alignHandler, - handleLinkClick + handleLinkClick, + bindTableToolsScroll, + unbindTableToolsScroll } from './index' import { defaultOption, iconOption, iconOptionMobileFirst, simpleToolbar } from './options' @@ -54,6 +56,9 @@ const initState = ({ api, reactive, computed, props }) => { quill: null, linkClickHandler: null, fileInput: null, + tableToolsScrollHandler: null, + tableToolsScrollRaf: null, + tableToolsScrollTargets: null, previewOptions: computed(() => api.computePreviewOptions()), previewImgUrl: '', showPreview: false, @@ -121,7 +126,9 @@ const initApi = ({ api, state, service, emit, props, nextTick, FluentEditor, Upl iconOption: mode === 'mobile-first' ? iconOptionMobileFirst : iconOption }), getOuterHTML: getOuterHTML(), - setToolbarTitle: setToolbarTitle({ state, t }) + setToolbarTitle: setToolbarTitle({ state, t }), + bindTableToolsScroll: bindTableToolsScroll({ state, api }), + unbindTableToolsScroll: unbindTableToolsScroll({ state }) }) } diff --git a/packages/vue/src/fluent-editor/__tests__/fluent-editor.test.ts b/packages/vue/src/fluent-editor/__tests__/fluent-editor.test.ts index fc37a140b7..267c5a5cf6 100644 --- a/packages/vue/src/fluent-editor/__tests__/fluent-editor.test.ts +++ b/packages/vue/src/fluent-editor/__tests__/fluent-editor.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test, afterEach } from 'vitest' -import { setToolbarTitle } from '@opentiny/vue-renderless/fluent-editor' +import { describe, expect, test, afterEach, vi } from 'vitest' +import { setToolbarTitle, syncTableControlPanelPosition } from '@opentiny/vue-renderless/fluent-editor' const t = (key: string) => key @@ -30,6 +30,7 @@ describe('fluent-editor toolbar title', () => { const { wrapper, setTitle } = createToolbar(` + @@ -39,6 +40,7 @@ describe('fluent-editor toolbar title', () => { setTitle() expect(wrapper.querySelector('.ql-align[value=""]')?.getAttribute('title')).toBe('ui.richText.alignPicker1') + expect(wrapper.querySelector('.ql-align:not([value])')?.getAttribute('title')).toBe('ui.richText.alignPicker1') expect(wrapper.querySelector('.ql-align[value="center"]')?.getAttribute('title')).toBe('ui.richText.alignPicker2') expect(wrapper.querySelector('.ql-align[value="right"]')?.getAttribute('title')).toBe('ui.richText.alignPicker3') expect(wrapper.querySelector('.ql-list[value="check"]')?.getAttribute('title')).toBe('ui.richText.listCheck') @@ -75,3 +77,150 @@ describe('fluent-editor toolbar title', () => { ) }) }) + +const mockRect = (top: number, height = 80) => + ({ + top, + left: 0, + bottom: top + height, + right: 200, + width: 200, + height, + x: 0, + y: top, + toJSON: () => ({}) + }) as DOMRect + +const createTableEditor = () => { + const container = document.createElement('div') + container.className = 'ql-container' + container.style.position = 'relative' + Object.defineProperty(container, 'clientHeight', { configurable: true, value: 400 }) + container.innerHTML = ` +
+
+
+
+
+
+
+ ` + const tableWrapper = document.createElement('div') + tableWrapper.className = 'quill-better-table-wrapper' + const table = document.createElement('table') + table.className = 'quill-better-table' + tableWrapper.appendChild(table) + container.querySelector('.ql-editor')!.appendChild(tableWrapper) + document.body.appendChild(container) + + const panel = container.querySelector('.qlbt-table-control-panel') as HTMLElement + const scrollBarPanel = container.querySelector('.qlbt-table-scroll-bar-panel') as HTMLElement + container.getBoundingClientRect = () => mockRect(100, 400) + tableWrapper.getBoundingClientRect = () => mockRect(160) + table.getBoundingClientRect = () => mockRect(160) + + const betterTable = { + columnTool: { + table, + domNode: panel + }, + tableScrollBar: { + table, + domNode: scrollBarPanel + }, + tableSelection: { + selectedTds: [] as { domNode: HTMLElement }[], + refreshHelpLinesPosition: vi.fn() + } + } + + const quill = { + root: container.querySelector('.ql-editor'), + getModule: () => betterTable + } + + return { container, panel, scrollBarPanel, table, tableWrapper, quill } +} + +describe('fluent-editor table control panel scroll', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + test('should keep row control panel aligned with table after content area scrolls', () => { + const { panel, tableWrapper, quill } = createTableEditor() + + syncTableControlPanelPosition(quill) + + expect(panel.style.top).toBe('35px') + expect(panel.style.marginTop).toBe('0px') + + tableWrapper.getBoundingClientRect = () => mockRect(70) + syncTableControlPanelPosition(quill) + + expect(panel.style.top).toBe('-55px') + expect(panel.style.marginTop).toBe('0px') + }) + + test('should include container scrollTop when the editor container is the scroller', () => { + const { container, panel, quill } = createTableEditor() + Object.defineProperty(container, 'scrollTop', { configurable: true, value: 40 }) + + syncTableControlPanelPosition(quill) + + expect(panel.style.top).toBe('75px') + }) + + test('should move table scroll bar with the table when content area scrolls', () => { + const { scrollBarPanel, table, quill } = createTableEditor() + + syncTableControlPanelPosition(quill) + + expect(scrollBarPanel.style.display).toBe('block') + expect(scrollBarPanel.style.top).toBe('139px') + expect(scrollBarPanel.style.marginTop).toBe('0px') + + table.getBoundingClientRect = () => mockRect(70) + syncTableControlPanelPosition(quill) + + expect(scrollBarPanel.style.top).toBe('49px') + }) + + test('should hide table scroll bar when the table is scrolled out of view', () => { + const { scrollBarPanel, table, quill } = createTableEditor() + + table.getBoundingClientRect = () => mockRect(10) + syncTableControlPanelPosition(quill) + + expect(scrollBarPanel.style.display).toBe('none') + }) + + test('should move selected cell border with the table when content area scrolls', () => { + const { quill } = createTableEditor() + const tableSelection = quill.getModule().tableSelection + tableSelection.selectedTds = [{ domNode: document.createElement('td') }] + + syncTableControlPanelPosition(quill) + + expect(tableSelection.refreshHelpLinesPosition).toHaveBeenCalledTimes(1) + }) + + test('should not refresh selection border when no cell is selected', () => { + const { quill } = createTableEditor() + const tableSelection = quill.getModule().tableSelection + + syncTableControlPanelPosition(quill) + + expect(tableSelection.refreshHelpLinesPosition).not.toHaveBeenCalled() + }) + + test('should still sync scroll bar when column tool node is missing', () => { + const { scrollBarPanel, quill } = createTableEditor() + quill.getModule().columnTool.domNode = null + + syncTableControlPanelPosition(quill) + + expect(scrollBarPanel.style.display).toBe('block') + expect(scrollBarPanel.style.top).toBe('139px') + }) +})