diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx index 8b08506f..d9ef0017 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenu/FloatingMenu.tsx @@ -12,7 +12,7 @@ import { import {useBooleanState} from 'src/react-utils'; -import {FloatingPopup, type FloatingPopupProps} from '../FloatingPopup'; +import {FloatingPopup, type FloatingPopupProps, type ReferenceType} from '../FloatingPopup'; const popupOffset: FloatingPopupProps['offset'] = { mainAxis: -9.5, @@ -21,7 +21,7 @@ const popupOffset: FloatingPopupProps['offset'] = { export type FloatingMenuProps = { dirtype: 'row' | 'column'; canDrag: boolean; - anchorElement: Element; + anchorElement: ReferenceType; dropdownItems: DropdownMenuProps['items']; switcherMouseProps?: Pick< ButtonButtonProps, diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx index 8d8586eb..b577cc73 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx @@ -1,5 +1,7 @@ import {useMemo} from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import type {ClientRectObject, VirtualElement} from '@floating-ui/dom'; import { ArrowDown, ArrowLeft, @@ -16,10 +18,13 @@ import {i18n} from 'src/i18n/yfm-table'; import type {DnDControlHandler} from '../../dnd/dnd'; import {FloatingMenu, type FloatingMenuProps} from '../FloatingMenu/FloatingMenu'; +type ControlType = FloatingMenuProps['dirtype']; + export type FloatingMenuControlProps = { - acnhorElement: Element; + cellElement: Element; + tableElement: Element; multiple: boolean; - type: FloatingMenuProps['dirtype']; + type: ControlType; dndHandler?: DnDControlHandler; onMenuOpenToggle: FloatingMenuProps['onOpenToggle']; onClearCellsClick: () => void; @@ -34,7 +39,8 @@ export const FloatingMenuControl: React.FC = type, multiple, dndHandler, - acnhorElement, + cellElement, + tableElement, onMenuOpenToggle, onClearCellsClick, onInsertBeforeClick, @@ -94,12 +100,17 @@ export const FloatingMenuControl: React.FC = ], ); + const anchor = useMemo( + () => getVirtualAnchor(type, tableElement, cellElement), + [type, tableElement, cellElement], + ); + return ( = /> ); }; + +function getVirtualAnchor( + type: ControlType, + tableElem: Element, + cellElem: Element, +): VirtualElement { + if (type === 'row') { + return { + contextElement: cellElem, + getBoundingClientRect() { + const cellRect = cellElem.getBoundingClientRect(); + const tableRect: ClientRectObject = tableElem.getBoundingClientRect().toJSON(); + + { + // fix table rect + tableRect.x += 1; + tableRect.width -= 2; + tableRect.left += 1; + tableRect.right -= 1; + } + + return { + // from table + x: tableRect.x, + width: tableRect.width, + left: tableRect.left, + right: tableRect.right, + // from cell + y: cellRect.y, + height: cellRect.height, + top: cellRect.top, + bottom: cellRect.top, + }; + }, + }; + } + + if (type === 'column') { + return { + contextElement: cellElem, + getBoundingClientRect() { + const cellRect: ClientRectObject = cellElem.getBoundingClientRect().toJSON(); + const tableRect = tableElem.getBoundingClientRect(); + + const EDGE_OFFSET = 16; + + const cellMiddle = cellRect.x + cellRect.width / 2; + + // left border of table + if (cellMiddle - EDGE_OFFSET <= tableRect.left) { + const visible = cellRect.right - tableRect.left; + cellRect.width = (visible - EDGE_OFFSET) * 2; + cellRect.left = cellRect.right - cellRect.width; + cellRect.x = cellRect.left; + } + + // right border of table + if (cellMiddle + EDGE_OFFSET >= tableRect.right) { + const visible = tableRect.right - cellRect.left; + cellRect.width = (visible - EDGE_OFFSET) * 2; + cellRect.right = cellRect.left + cellRect.width; + } + + return cellRect; + }, + }; + } + + throw new Error(`Unknown control type: ${type}`); +} diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusButton/FloatingPlusButton.tsx b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusButton/FloatingPlusButton.tsx index 06e80eef..e02a6f67 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusButton/FloatingPlusButton.tsx +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusButton/FloatingPlusButton.tsx @@ -1,6 +1,6 @@ -import {useState} from 'react'; +import {forwardRef, useState} from 'react'; -import {FloatingPopup, type FloatingPopupProps} from '../FloatingPopup'; +import {FloatingPopup, type FloatingPopupProps, type FloatingPopupRef} from '../FloatingPopup'; import {InsertCursor, type InsertCursorProps} from './InsertCursor'; import {PlusButton, type PlusButtonProps} from './PlusButton'; @@ -18,18 +18,23 @@ const offsetByType: Record & +export type FloatingPlusButtonRef = FloatingPopupRef & {}; + +export type FloatingPlusButtonProps = Pick & + Pick & Pick; -export const FloatingPlusButton: React.FC = - function YfmTableFloatingPlusButton({anchor, type, ...btnProps}) { +export const FloatingPlusButton = forwardRef( + function YfmTableFloatingPlusButton({anchor, type, floatingStyles, ...btnProps}, ref) { const [hovered, setHovered] = useState(false); return ( <> = {hovered && } ); - }; + }, +); diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusControl/FloatingPlusControl.tsx b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusControl/FloatingPlusControl.tsx index 82e20923..bda82123 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusControl/FloatingPlusControl.tsx +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPlusControl/FloatingPlusControl.tsx @@ -1,12 +1,20 @@ -import {memo, useMemo} from 'react'; +import {memo, useEffect, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line import/no-extraneous-dependencies import type {VirtualElement} from '@floating-ui/dom'; +import {useEffectOnce} from 'react-use'; -import {FloatingPlusButton, type FloatingPlusButtonProps} from '../FloatingPlusButton'; +import {useRafThrottle} from '../../hooks/use-raf-throttle'; +import { + FloatingPlusButton, + type FloatingPlusButtonProps, + type FloatingPlusButtonRef, +} from '../FloatingPlusButton'; + +type ControlType = FloatingPlusButtonProps['type']; export type FloatingPlusControlProps = { - type: FloatingPlusButtonProps['type']; + type: ControlType; index: number; cellElem: Element; tableElem: Element; @@ -48,6 +56,57 @@ export const FloatingPlusControl = memo( [cellElem, tableElem, type], ); - return onClick(index)} />; + const [visible, setVisible] = useState(() => shouldBeVisible(type, cellElem, tableElem)); + const buttonRef = useRef(null); + + const updateVisibility = () => { + const newVisible = shouldBeVisible(type, cellElem, tableElem); + if (visible !== newVisible) setVisible(newVisible); + }; + + const onChange = useRafThrottle(() => { + buttonRef.current?.forceUpdate(); + updateVisibility(); + }); + + // Update after first render + useEffectOnce(updateVisibility); + + useEffect(() => { + if (type !== 'column') return undefined; + + const observer = new ResizeObserver(onChange); + observer.observe(tableElem); + tableElem.addEventListener('scroll', onChange); + + return () => { + observer.unobserve(tableElem); + tableElem.removeEventListener('scroll', onChange); + }; + }, [tableElem, onChange, type]); + + return ( + onClick(index)} + floatingStyles={visible ? undefined : {display: 'none'}} + /> + ); }, ); + +function shouldBeVisible(type: ControlType, cellElem: Element, tableElem: Element): boolean { + if (type !== 'column') return true; + + const THRESHOLD = 4; // px + + const cellRect = cellElem.getBoundingClientRect(); + const tableRect = tableElem.getBoundingClientRect(); + + return ( + tableRect.left - cellRect.right <= THRESHOLD && + cellRect.right - tableRect.right <= THRESHOLD + ); +} diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPopup/FloatingPopup.tsx b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPopup/FloatingPopup.tsx index 93fc7722..4312f3e3 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPopup/FloatingPopup.tsx +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingPopup/FloatingPopup.tsx @@ -3,7 +3,7 @@ /// This fork does not use floating focus manager /// -import {useEffect, useMemo} from 'react'; +import {forwardRef, useEffect, useImperativeHandle, useMemo} from 'react'; // eslint-disable-next-line import/no-extraneous-dependencies import { @@ -27,6 +27,12 @@ import './FloatingPopup.scss'; const b = cn('yfm-table-floating-popup'); +export type {ReferenceType}; + +export type FloatingPopupRef = { + forceUpdate: () => void; +}; + export type FloatingPopupProps = QAProps & DOMProps & { open?: boolean; @@ -38,87 +44,88 @@ export type FloatingPopupProps = QAProps & floatingStyles?: React.CSSProperties; }; -export const FloatingPopup: React.FC = function YfmTableFloatingPopup( - props: FloatingPopupProps, -) { - const { - anchorElement, - zIndex = 1000, - style, - className, - qa, - children, - floatingStyles: floatingStylesFromProps, - } = props; - - const nodeId = useFloatingNodeId(); - const parentId = useFloatingParentNodeId(); - - const middleware = useMemo(() => [offsetMiddleware(props.offset)], [props.offset]); - - const { - refs, - elements, - floatingStyles, - placement, - context: {open}, - update, - } = useFloating({ - nodeId, - open: props.open, - placement: props.placement, - middleware, - }); - - const {getFloatingProps} = useInteractions(); - - useEffect(() => { - if (anchorElement !== undefined && anchorElement !== refs.reference.current) { - refs.setReference(anchorElement); - } - }, [anchorElement, refs]); - - useEffect(() => { - if (elements.reference && elements.floating) { - return autoUpdate(elements.reference, elements.floating, update); - } - return undefined; - }, [elements, update]); - - function wrapper(node: JSX.Element) { - if (parentId === null) { - return {node}; +export const FloatingPopup = forwardRef( + function YfmTableFloatingPopup(props, ref) { + const { + anchorElement, + zIndex = 1000, + style, + className, + qa, + children, + floatingStyles: floatingStylesFromProps, + } = props; + + const nodeId = useFloatingNodeId(); + const parentId = useFloatingParentNodeId(); + + const middleware = useMemo(() => [offsetMiddleware(props.offset)], [props.offset]); + + const { + refs, + floatingStyles, + placement, + context: {open}, + update, + } = useFloating({ + nodeId, + open: props.open, + placement: props.placement, + middleware, + whileElementsMounted: autoUpdate, + }); + + useImperativeHandle( + ref, + () => ({ + forceUpdate: update, + }), + [update], + ); + + const {getFloatingProps} = useInteractions(); + + useEffect(() => { + if (anchorElement !== undefined && anchorElement !== refs.reference.current) { + refs.setReference(anchorElement); + } + }, [anchorElement, refs]); + + function wrapper(node: JSX.Element) { + if (parentId === null) { + return {node}; + } + + return node; } - return node; - } - - return wrapper( - - {open ? ( - -
-
- {children} + return wrapper( + + {open ? ( + +
+
+ {children} +
-
- - ) : null} - , - ); -}; + + ) : null} + , + ); + }, +); diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-drop-cursor.ts b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-drop-cursor.ts index baee7ac2..e0d68a3c 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-drop-cursor.ts +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-drop-cursor.ts @@ -1,6 +1,6 @@ import {findParentNodeClosestToPos} from '#pm/utils'; import type {EditorView} from '#pm/view'; -import {isTableCellNode, isTableNode} from 'src/table-utils'; +import {isTableCellNode, isTableNode, isTableRowNode} from 'src/table-utils'; /** Same as `DropCursorOptions` from _prosemirror-dropcursor_ package */ export type DropCursorParams = { @@ -131,6 +131,45 @@ export class DropCursor { } } +export class TableRowDropCursor extends DropCursor { + update() { + const cursorPos = this.getPos(); + if (cursorPos === null) return; + + const $cursorPos = this.editorView.state.doc.resolve(cursorPos); + const parentTable = findParentNodeClosestToPos($cursorPos, isTableNode); + if (!parentTable) return; + + let side: 'top' | 'bottom'; + let trowPos: number; + if ($cursorPos.nodeAfter && isTableRowNode($cursorPos.nodeAfter)) { + side = 'top'; + trowPos = cursorPos; + } else if ($cursorPos.nodeBefore && isTableRowNode($cursorPos.nodeBefore)) { + side = 'bottom'; + trowPos = cursorPos - $cursorPos.nodeBefore.nodeSize; + } else { + this.cursorElem?.remove(); + this.cursorElem = null; + return; + } + + const trElem = this.editorView.nodeDOM(trowPos); + const tableElem = this.editorView.nodeDOM(parentTable.pos); + + const trRect = (trElem as HTMLElement).getBoundingClientRect(); + const tableRect = (tableElem as HTMLElement).getBoundingClientRect(); + + const rect: Rect = { + top: trRect[side] - this.width / 2, + bottom: trRect[side] + this.width / 2, + left: tableRect.left, + right: tableRect.right, + }; + this.render(rect, {isBlock: true}); + } +} + export class TableColumnDropCursor extends DropCursor { update() { const cursorPos = this.getPos(); diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-ghost.ts b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-ghost.ts index 68d211b5..e0adc77c 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-ghost.ts +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd-ghost.ts @@ -114,6 +114,7 @@ export class YfmTableDnDGhost { ): BuildGhostResult { let shiftX = 0; let shiftY = 0; + let isHorizontallyScrolled = false; const document = view.dom.ownerDocument; const container = this._buildGhostContainer(view); @@ -122,10 +123,12 @@ export class YfmTableDnDGhost { const tbody = table.appendChild(document.createElement('tbody')); { - const tablePos = tableDesc.pos; - const tableNode = view.domAtPos(tablePos + 1).node; - const rect = (tableNode as Element).getBoundingClientRect(); - table.style.width = rect.width + 'px'; + const tableNode = view.domAtPos(tableDesc.pos + 1).node; + isHorizontallyScrolled = (tableNode as Element).scrollLeft > 0; + + const tbodyNode = view.domAtPos(tableDesc.bodyPos + 1).node; + const tbodyRect = (tbodyNode as Element).getBoundingClientRect(); + tbody.style.width = tbodyRect.width + 'px'; } const range = tableDesc.base.getRowRanges()[rangeIdx]; @@ -150,6 +153,10 @@ export class YfmTableDnDGhost { } } + if (isHorizontallyScrolled) { + shiftX = 0; + } + removeIdAttributes(table); return {domElement: container, shiftX, shiftY}; diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts index 556f601b..a4dd7e59 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts @@ -20,9 +20,10 @@ import {hideHoverDecos} from '../plugins/focus-plugin'; import {getSelectedCellsForColumns, getSelectedCellsForRows} from '../utils'; import { + type DropCursor, type DropCursorParams, - DropCursor as RowDropCursor, TableColumnDropCursor, + TableRowDropCursor, } from './dnd-drop-cursor'; import {YfmTableDnDGhost} from './dnd-ghost'; @@ -85,7 +86,7 @@ abstract class YfmTableDnDAbstractHandler implements TableHandler, DnDControlHan protected readonly _cellGetPos: () => number | undefined; protected readonly _editorView: EditorView; protected readonly _logger: Logger2.ILogger; - protected readonly _dropCursor: RowDropCursor; + protected readonly _dropCursor: DropCursor; private __cellNode: Node; private __dragging = false; @@ -94,7 +95,7 @@ abstract class YfmTableDnDAbstractHandler implements TableHandler, DnDControlHan constructor( view: EditorView, - params: Omit & {dropCursor: RowDropCursor}, + params: Omit & {dropCursor: DropCursor}, ) { this._editorView = view; this.__cellNode = params.cellNode; @@ -192,7 +193,7 @@ class YfmTableRowDnDHandler extends YfmTableDnDAbstractHandler { super(view, { ...params, logger: params.logger.nested({component: 'row-dnd-handler'}), - dropCursor: new RowDropCursor(view, params.dropCursor), + dropCursor: new TableRowDropCursor(view, params.dropCursor), }); } diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/hooks/use-raf-throttle.ts b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/hooks/use-raf-throttle.ts new file mode 100644 index 00000000..720bd6b0 --- /dev/null +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/hooks/use-raf-throttle.ts @@ -0,0 +1,25 @@ +import {useCallback, useEffect, useRef} from 'react'; + +import {useLatest} from 'react-use'; + +export function useRafThrottle(fn: () => void): () => void { + const refFn = useLatest(fn); + const refHandle = useRef(null); + + useEffect(() => { + return () => { + if (refHandle.current !== null) { + cancelAnimationFrame(refHandle.current); + } + }; + }, [refFn]); + + return useCallback(() => { + if (refHandle.current === null) { + refHandle.current = requestAnimationFrame(() => { + refHandle.current = null; + refFn.current(); + }); + } + }, [refFn]); +} diff --git a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx index 2a9e0bd7..c8f97fa2 100644 --- a/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx +++ b/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx @@ -66,6 +66,7 @@ class YfmTableCellView implements NodeView { private _decoRowUniqKey: number | null = null; private _decoColumnUniqKey: number | null = null; private _cellInfo: null | { + tablePos: number; rowIndex: number; columnIndex: number; rowRange: Readonly; @@ -103,14 +104,18 @@ class YfmTableCellView implements NodeView { () => { if (!this._cellInfo) return null; - const {showRowControl, showColumnControl, rowRange, columnRange} = this._cellInfo; + const {showRowControl, showColumnControl, rowRange, columnRange, tablePos} = + this._cellInfo; + + const tableElem = this._view.domAtPos(tablePos + 1).node as Element; return ( {showRowControl && ( 1} onMenuOpenToggle={this._onRowControlOpenToggle} @@ -124,7 +129,8 @@ class YfmTableCellView implements NodeView { {showColumnControl && ( 1} onMenuOpenToggle={this._onColumnControlOpenToggle} @@ -154,6 +160,7 @@ class YfmTableCellView implements NodeView { if (cellInfo && (cellInfo.cell.row === 0 || cellInfo.cell.column === 0)) { const info = (this._cellInfo = { + tablePos: cellInfo.table.pos, rowIndex: cellInfo.cell.row, columnIndex: cellInfo.cell.column, showRowControl: false as boolean, diff --git a/src/table-utils/table-desc.ts b/src/table-utils/table-desc.ts index 8072f93d..9c178edf 100644 --- a/src/table-utils/table-desc.ts +++ b/src/table-utils/table-desc.ts @@ -380,6 +380,10 @@ class TableDescBinded { readonly pos: number; readonly base: TableDesc; + get bodyPos(): number { + return this.pos + 1; + } + get rows(): number { return this.base.rows; }