mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 09:14:07 +08:00
feat: table enhancement (#2191)
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { columnResizingPluginKey } from "@tiptap/pm/tables";
|
||||
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
|
||||
import { Menu, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface CellChevronProps {
|
||||
editor: Editor;
|
||||
cellPos: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
export const CellChevron = React.memo(function CellChevron({
|
||||
editor,
|
||||
cellPos,
|
||||
tableNode,
|
||||
tablePos,
|
||||
}: CellChevronProps) {
|
||||
const { t } = useTranslation();
|
||||
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
|
||||
|
||||
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||
placement: "top-end",
|
||||
// crossAxis pulls the chevron INWARD from the cell's right edge. We need
|
||||
// enough inset that we don't overlap PM-tables' column-resize hot zone
|
||||
// (~5px wide around the column boundary). Without this, hovering near the
|
||||
// column edge picks up the chevron's `cursor: pointer` instead of
|
||||
// `col-resize`, and a drag near the edge clicks the chevron.
|
||||
middleware: [offset({ mainAxis: -22, crossAxis: -10 }), hide()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
strategy: "absolute",
|
||||
});
|
||||
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
|
||||
|
||||
useEffect(() => {
|
||||
refs.setReference(cellDom);
|
||||
}, [cellDom, refs]);
|
||||
|
||||
// Hide the chevron while the user is resizing a column. PM-tables sets
|
||||
// `activeHandle > -1` whenever the mouse is near a column boundary OR
|
||||
// actively dragging it. Either way we don't want the chevron in the way.
|
||||
const isResizingColumn = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) return false;
|
||||
const state = columnResizingPluginKey.getState(ctx.editor.state) as
|
||||
| { activeHandle: number }
|
||||
| undefined;
|
||||
return !!state && state.activeHandle > -1;
|
||||
},
|
||||
});
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
const current = editor.state.selection;
|
||||
|
||||
// Preserve an existing multi-cell CellSelection that already covers
|
||||
// this cell so merge etc. operate on the user's whole range.
|
||||
let preserveExisting = false;
|
||||
if (isCellSelection(current)) {
|
||||
current.forEachCell((_node, pos) => {
|
||||
if (pos === cellPos) preserveExisting = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (!preserveExisting) {
|
||||
// Drop a collapsed cursor inside the cell rather than a single-cell
|
||||
// CellSelection — PM-tables paints the latter as a text-range
|
||||
// highlight on the cell content.
|
||||
try {
|
||||
const $inside = editor.state.doc.resolve(cellPos + 1);
|
||||
const sel = TextSelection.near($inside, 1);
|
||||
editor.view.dispatch(editor.state.tr.setSelection(sel));
|
||||
} catch {}
|
||||
}
|
||||
editor.commands.freezeHandles();
|
||||
}, [editor, cellPos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
if (!cellDom) return null;
|
||||
if (isResizingColumn) return null;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
withinPortal
|
||||
shadow="md"
|
||||
>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
|
||||
}}
|
||||
className={clsx(classes.cellChevron)}
|
||||
aria-label={t("Cell actions")}
|
||||
>
|
||||
<IconChevronDown size={14} />
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<CellChevronMenu
|
||||
editor={editor}
|
||||
cellPos={cellPos}
|
||||
tableNode={tableNode}
|
||||
tablePos={tablePos}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
|
||||
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
|
||||
import { ColumnHandleMenu } from "./menus/column-handle-menu";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface ColumnHandleProps {
|
||||
editor: Editor;
|
||||
index: number;
|
||||
anchorPos: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
export const ColumnHandle = React.memo(function ColumnHandle({
|
||||
editor,
|
||||
index,
|
||||
anchorPos,
|
||||
tableNode,
|
||||
tablePos,
|
||||
}: ColumnHandleProps) {
|
||||
const { t } = useTranslation();
|
||||
// Hold the cell DOM in a ref-backed state so we never unmount the handle
|
||||
// mid-drag. A remote edit can transiently flip `nodeDOM(anchorPos)` to null
|
||||
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
|
||||
// unmounting the source element here would make pragmatic-dnd silently
|
||||
// abort the active drag.
|
||||
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||
useEffect(() => {
|
||||
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
|
||||
lastCellDomRef.current = lookupCellDom;
|
||||
setCellDom(lookupCellDom);
|
||||
}
|
||||
}, [lookupCellDom]);
|
||||
|
||||
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||
placement: "top",
|
||||
middleware: [offset(-4), hide()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
|
||||
|
||||
useEffect(() => {
|
||||
refs.setReference(cellDom);
|
||||
}, [cellDom, refs]);
|
||||
|
||||
// `cellDom` is inside the table, so `closest('.tableWrapper')` finds the
|
||||
// wrapper for this drag's auto-scroll. The handle itself lives in a
|
||||
// floating layer outside the editor DOM, so we can't walk up from it.
|
||||
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
|
||||
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
const closeMenu = useCallback(() => setMenuOpened(false), []);
|
||||
useTableHandleDrag(editor, "col", handleEl, wrapper, closeMenu);
|
||||
|
||||
const { onOpen, onClose } = useColumnRowMenuLifecycle({
|
||||
editor,
|
||||
orientation: "col",
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
});
|
||||
|
||||
if (!cellDom) return null;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
position="bottom-start"
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
withinPortal
|
||||
shadow="md"
|
||||
>
|
||||
<Menu.Target>
|
||||
<div
|
||||
ref={(node) => {
|
||||
refs.setFloating(node);
|
||||
setHandleEl(node);
|
||||
}}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
|
||||
}}
|
||||
className={clsx(classes.handle, classes.columnHandle)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("Column actions")}
|
||||
>
|
||||
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
|
||||
<GripIcon />
|
||||
</span>
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<ColumnHandleMenu
|
||||
editor={editor}
|
||||
index={index}
|
||||
tableNode={tableNode}
|
||||
tablePos={tablePos}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
function GripIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.handle {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: rgba(55, 53, 47, 0.45);
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid rgba(55, 53, 47, 0.12);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
cursor: grab;
|
||||
padding: 0;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
|
||||
@mixin dark {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: var(--mantine-color-dark-7);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.handle:hover {
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-0)
|
||||
);
|
||||
}
|
||||
|
||||
.handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.columnHandle {
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.columnHandle svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rowHandle {
|
||||
width: 16px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cellChevron {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border: 1px solid rgba(55, 53, 47, 0.12);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
|
||||
@mixin dark {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.cellChevron:hover {
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-8),
|
||||
var(--mantine-color-dark-0)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.cellChevron {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.handle,
|
||||
.cellChevron {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
|
||||
|
||||
interface Args {
|
||||
editor: Editor;
|
||||
orientation: Orientation;
|
||||
index: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
export function useColumnRowMenuLifecycle({
|
||||
editor,
|
||||
orientation,
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
}: Args) {
|
||||
const onOpen = useCallback(() => {
|
||||
const selection = buildRowOrColumnSelection(
|
||||
editor.state,
|
||||
tableNode,
|
||||
tablePos,
|
||||
orientation,
|
||||
index,
|
||||
);
|
||||
const tr = editor.state.tr;
|
||||
if (selection) tr.setSelection(selection);
|
||||
editor.view.dispatch(tr);
|
||||
editor.commands.freezeHandles();
|
||||
}, [editor, orientation, index, tableNode, tablePos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
return { onOpen, onClose };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
|
||||
type Scope =
|
||||
| { kind: "col"; index: number }
|
||||
| { kind: "row"; index: number }
|
||||
| { kind: "cell"; cellPos: number };
|
||||
|
||||
export function useTableClear(
|
||||
editor: Editor,
|
||||
tableNode: ProseMirrorNode,
|
||||
tablePos: number,
|
||||
scope: Scope,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
const tr = editor.state.tr;
|
||||
const tableStart = tablePos + 1;
|
||||
const map = TableMap.get(tableNode);
|
||||
const paragraph = editor.schema.nodes.paragraph;
|
||||
if (!paragraph) return;
|
||||
|
||||
const cellOffsets: number[] = [];
|
||||
|
||||
if (scope.kind === "col") {
|
||||
for (let row = 0; row < map.height; row++) {
|
||||
cellOffsets.push(map.map[row * map.width + scope.index]);
|
||||
}
|
||||
} else if (scope.kind === "row") {
|
||||
for (let col = 0; col < map.width; col++) {
|
||||
cellOffsets.push(map.map[scope.index * map.width + col]);
|
||||
}
|
||||
}
|
||||
|
||||
const targets =
|
||||
scope.kind === "cell"
|
||||
? [scope.cellPos]
|
||||
: Array.from(new Set(cellOffsets)).map((o) => tableStart + o);
|
||||
|
||||
// Process in reverse position order so earlier replacements don't shift later ones.
|
||||
targets.sort((a, b) => b - a);
|
||||
|
||||
for (const cellPos of targets) {
|
||||
const node = tr.doc.nodeAt(cellPos);
|
||||
if (!node) continue;
|
||||
const start = cellPos + 1;
|
||||
const end = cellPos + node.nodeSize - 1;
|
||||
tr.replaceWith(start, end, paragraph.create());
|
||||
}
|
||||
|
||||
if (tr.docChanged) editor.view.dispatch(tr);
|
||||
}, [editor, tableNode, tablePos, scope]);
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
|
||||
import {
|
||||
autoScrollForElements,
|
||||
autoScrollWindowForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { getTableHandlePluginSpec } from "@docmost/editor-ext";
|
||||
|
||||
// Uses pragmatic-drag-and-drop instead of native HTML5 DnD because the native
|
||||
// dragstart→dragover→drop lifecycle was being silently cancelled
|
||||
export function useTableHandleDrag(
|
||||
editor: Editor,
|
||||
orientation: "col" | "row",
|
||||
element: HTMLElement | null,
|
||||
wrapper: HTMLElement | null,
|
||||
onDragStart?: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
getInitialData: () => ({ type: `table-${orientation}` }),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
// We render our own floating preview via PreviewController, so hide
|
||||
// the native drag image entirely.
|
||||
disableNativeDragPreview({ nativeSetDragImage });
|
||||
},
|
||||
onDragStart: ({ location }) => {
|
||||
// The menu (if open from a prior click on the handle) won't dismiss
|
||||
// on its own — pragmatic-dnd swallows the events Mantine listens for.
|
||||
onDragStart?.();
|
||||
const spec = getTableHandlePluginSpec(editor);
|
||||
if (!spec) return;
|
||||
const { clientX, clientY } = location.initial.input;
|
||||
spec.startDragFromHandle(orientation, clientX, clientY);
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
const spec = getTableHandlePluginSpec(editor);
|
||||
if (!spec) return;
|
||||
const { clientX, clientY } = location.current.input;
|
||||
spec.updateDragPosition(clientX, clientY);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
const spec = getTableHandlePluginSpec(editor);
|
||||
if (!spec) return;
|
||||
const { clientX, clientY } = location.current.input;
|
||||
// Make sure the final position is recorded before committing the drop.
|
||||
spec.updateDragPosition(clientX, clientY);
|
||||
spec.commitDrop();
|
||||
spec.endDrag();
|
||||
},
|
||||
}),
|
||||
// Wrapper owns horizontal auto-scroll (it has `overflow-x: auto`);
|
||||
// window owns vertical. Locking each axis prevents the window's
|
||||
// horizontal auto-scroll from running when the cursor approaches
|
||||
// the viewport edge — without the cap, the preview's `left` follows
|
||||
// the cursor past the viewport, the page widens to contain it, the
|
||||
// plugin scrolls the now-wider page further, and the loop never
|
||||
// ends.
|
||||
// Only the column handle registers wrapper auto-scroll (rows can't
|
||||
// scroll horizontally) — registering twice on the same wrapper
|
||||
// triggers a dev-mode warning from pragmatic-dnd-auto-scroll.
|
||||
orientation === "col" &&
|
||||
wrapper &&
|
||||
!wrapper.classList.contains("tableWrapperNoOverflow")
|
||||
? autoScrollForElements({
|
||||
element: wrapper,
|
||||
getAllowedAxis: () => "horizontal",
|
||||
})
|
||||
: () => {},
|
||||
autoScrollWindowForElements({ getAllowedAxis: () => "vertical" }),
|
||||
);
|
||||
}, [editor, orientation, element, wrapper, onDragStart]);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { TableDndKey, TableHandleState } from "@docmost/editor-ext";
|
||||
|
||||
const FALLBACK: TableHandleState = {
|
||||
hoveringCell: null,
|
||||
tableNode: null,
|
||||
tablePos: null,
|
||||
dragging: null,
|
||||
frozen: false,
|
||||
};
|
||||
|
||||
export function useTableHandleState(editor: Editor | null): TableHandleState {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) return null;
|
||||
return TableDndKey.getState(ctx.editor.state) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
return state ?? FALLBACK;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { moveColumn, moveRow } from "@docmost/editor-ext";
|
||||
|
||||
export type MoveDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
export function useTableMoveRowColumn(
|
||||
editor: Editor,
|
||||
orientation: "col" | "row",
|
||||
index: number,
|
||||
direction: MoveDirection,
|
||||
tableNode: ProseMirrorNode,
|
||||
tablePos: number,
|
||||
) {
|
||||
const target =
|
||||
direction === "left" || direction === "up" ? index - 1 : index + 1;
|
||||
|
||||
const maxIndex = useMemo(() => {
|
||||
const map = TableMap.get(tableNode);
|
||||
return orientation === "col" ? map.width - 1 : map.height - 1;
|
||||
}, [tableNode, orientation]);
|
||||
|
||||
const canMove = target >= 0 && target <= maxIndex;
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!canMove) return;
|
||||
const tr = editor.state.tr;
|
||||
const moved =
|
||||
orientation === "col"
|
||||
? moveColumn({
|
||||
tr,
|
||||
originIndex: index,
|
||||
targetIndex: target,
|
||||
select: true,
|
||||
pos: tablePos + 1,
|
||||
})
|
||||
: moveRow({
|
||||
tr,
|
||||
originIndex: index,
|
||||
targetIndex: target,
|
||||
select: true,
|
||||
pos: tablePos + 1,
|
||||
});
|
||||
if (moved) editor.view.dispatch(tr);
|
||||
}, [editor, orientation, index, target, tablePos, canMove]);
|
||||
|
||||
return { canMove, handleMove };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
convertArrayOfRowsToTableNode,
|
||||
convertTableNodeToArrayOfRows,
|
||||
transpose,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
getCellSortText,
|
||||
isCellEmpty,
|
||||
isHeaderCell,
|
||||
type SortDirection,
|
||||
type SortableItem,
|
||||
sortItems,
|
||||
weaveItems,
|
||||
} from "../lib/sort-cells";
|
||||
|
||||
interface Args {
|
||||
editor: Editor;
|
||||
orientation: "col" | "row";
|
||||
index: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
function tableHasMergedCells(tableNode: ProseMirrorNode): boolean {
|
||||
for (let r = 0; r < tableNode.childCount; r++) {
|
||||
const row = tableNode.child(r);
|
||||
for (let c = 0; c < row.childCount; c++) {
|
||||
const { colspan = 1, rowspan = 1 } = row.child(c).attrs;
|
||||
if (colspan > 1 || rowspan > 1) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllHeader(cells: (ProseMirrorNode | null)[]): boolean {
|
||||
return cells.every((c) => c !== null && isHeaderCell(c));
|
||||
}
|
||||
|
||||
export function useTableSort({
|
||||
editor,
|
||||
orientation,
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
direction,
|
||||
}: Args) {
|
||||
const canSort = useMemo(() => {
|
||||
if (tableHasMergedCells(tableNode)) return false;
|
||||
|
||||
const rows = convertTableNodeToArrayOfRows(tableNode);
|
||||
const axes = orientation === "col" ? rows : transpose(rows);
|
||||
if (axes.length < 2) return false;
|
||||
|
||||
return axes.some((cells) => {
|
||||
if (isAllHeader(cells)) return false;
|
||||
const sortCell = cells[index];
|
||||
return !!sortCell && !isCellEmpty(sortCell);
|
||||
});
|
||||
}, [tableNode, orientation, index]);
|
||||
|
||||
const handleSort = useCallback(() => {
|
||||
if (!canSort) return;
|
||||
|
||||
const rows = convertTableNodeToArrayOfRows(tableNode);
|
||||
const axes = orientation === "col" ? rows : transpose(rows);
|
||||
|
||||
const items: SortableItem<(ProseMirrorNode | null)[]>[] = axes.map(
|
||||
(cells, originalOrder) => {
|
||||
const sortCell = cells[index];
|
||||
return {
|
||||
payload: cells,
|
||||
text: sortCell ? getCellSortText(sortCell) : "",
|
||||
isHeader: isAllHeader(cells),
|
||||
isEmpty: !sortCell || isCellEmpty(sortCell),
|
||||
originalOrder,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const dataItems = items.filter((it) => !it.isHeader);
|
||||
const sortedData = sortItems(dataItems, direction);
|
||||
const woven = weaveItems(items, sortedData);
|
||||
|
||||
const newAxes = woven.map((it) => it.payload);
|
||||
const newRows = orientation === "col" ? newAxes : transpose(newAxes);
|
||||
|
||||
const newTable = convertArrayOfRowsToTableNode(tableNode, newRows);
|
||||
|
||||
const tr = editor.state.tr;
|
||||
tr.replaceWith(tablePos, tablePos + tableNode.nodeSize, newTable);
|
||||
|
||||
if (tr.docChanged) editor.view.dispatch(tr);
|
||||
}, [editor, tableNode, tablePos, orientation, index, direction, canSort]);
|
||||
|
||||
return { canSort, handleSort };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
|
||||
export type Orientation = "col" | "row";
|
||||
|
||||
export function buildRowOrColumnSelection(
|
||||
state: EditorState,
|
||||
tableNode: ProseMirrorNode,
|
||||
tablePos: number,
|
||||
orientation: Orientation,
|
||||
index: number,
|
||||
): CellSelection | null {
|
||||
const map = TableMap.get(tableNode);
|
||||
const tableStart = tablePos + 1;
|
||||
|
||||
if (orientation === "col") {
|
||||
if (index < 0 || index >= map.width) return null;
|
||||
const firstCellPos = tableStart + map.map[index];
|
||||
const lastCellPos =
|
||||
tableStart + map.map[(map.height - 1) * map.width + index];
|
||||
const $first = state.doc.resolve(firstCellPos);
|
||||
const $last = state.doc.resolve(lastCellPos);
|
||||
return CellSelection.colSelection($first, $last);
|
||||
}
|
||||
|
||||
if (index < 0 || index >= map.height) return null;
|
||||
const firstCellPos = tableStart + map.map[index * map.width];
|
||||
const lastCellPos =
|
||||
tableStart + map.map[index * map.width + (map.width - 1)];
|
||||
const $first = state.doc.resolve(firstCellPos);
|
||||
const $last = state.doc.resolve(lastCellPos);
|
||||
return CellSelection.rowSelection($first, $last);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface SortableItem<T> {
|
||||
payload: T;
|
||||
text: string;
|
||||
isHeader: boolean;
|
||||
isEmpty: boolean;
|
||||
originalOrder: number;
|
||||
}
|
||||
|
||||
const HEADER_TYPE_NAMES = new Set(["tableHeader", "table_header"]);
|
||||
|
||||
export function isHeaderCell(node: ProseMirrorNode): boolean {
|
||||
if (HEADER_TYPE_NAMES.has(node.type.name)) return true;
|
||||
return node.attrs?.header === true;
|
||||
}
|
||||
|
||||
export function getCellSortText(node: ProseMirrorNode): string {
|
||||
let text = "";
|
||||
node.descendants((child) => {
|
||||
if (child.isText) text += child.text ?? "";
|
||||
return true;
|
||||
});
|
||||
return text.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isCellEmpty(node: ProseMirrorNode): boolean {
|
||||
return getCellSortText(node) === "";
|
||||
}
|
||||
|
||||
export const collator = new Intl.Collator(undefined, {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
});
|
||||
|
||||
export function sortItems<T>(
|
||||
data: SortableItem<T>[],
|
||||
direction: SortDirection,
|
||||
): SortableItem<T>[] {
|
||||
return [...data].sort((a, b) => {
|
||||
if (a.isEmpty && !b.isEmpty) return 1;
|
||||
if (!a.isEmpty && b.isEmpty) return -1;
|
||||
if (a.isEmpty && b.isEmpty) return a.originalOrder - b.originalOrder;
|
||||
const cmp = collator.compare(a.text, b.text);
|
||||
return direction === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
export function weaveItems<T>(
|
||||
all: SortableItem<T>[],
|
||||
sortedData: SortableItem<T>[],
|
||||
): SortableItem<T>[] {
|
||||
const dataQueue = [...sortedData];
|
||||
return all.map((item) => (item.isHeader ? item : dataQueue.shift()!));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import {
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AlignmentSubmenuProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export const AlignmentSubmenu = React.memo(function AlignmentSubmenu({
|
||||
editor,
|
||||
}: AlignmentSubmenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Menu.Sub position="right-start">
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconAlignLeft size={16} />}>
|
||||
{t("Text alignment")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconAlignLeft size={16} />}
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
>
|
||||
{t("Align left")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconAlignCenter size={16} />}
|
||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||
>
|
||||
{t("Align center")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconAlignRight size={16} />}
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
>
|
||||
{t("Align right")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { ColorSwatch, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconBoxMargin,
|
||||
IconColumnInsertRight,
|
||||
IconColumnRemove,
|
||||
IconEraser,
|
||||
IconPalette,
|
||||
IconRowInsertBottom,
|
||||
IconRowRemove,
|
||||
IconSquareToggle,
|
||||
IconTableRow,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTableClear } from "../hooks/use-table-clear";
|
||||
import { TABLE_COLORS } from "../../table-background-color";
|
||||
import { AlignmentSubmenu } from "./alignment-submenu";
|
||||
|
||||
interface CellChevronMenuProps {
|
||||
editor: Editor;
|
||||
cellPos: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
export const CellChevronMenu = React.memo(function CellChevronMenu({
|
||||
editor,
|
||||
cellPos,
|
||||
tableNode,
|
||||
tablePos,
|
||||
}: CellChevronMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const clearCell = useTableClear(editor, tableNode, tablePos, {
|
||||
kind: "cell",
|
||||
cellPos,
|
||||
});
|
||||
|
||||
const setBackground = (color: string, name: string) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? name : null,
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? name : null,
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Sub position="right-start">
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
|
||||
{t("Background color")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{TABLE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => setBackground(c.color, c.name)}
|
||||
aria-label={t(c.name)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<ColorSwatch
|
||||
color={c.color || "#ffffff"}
|
||||
size={22}
|
||||
style={{
|
||||
border: c.color === "" ? "1px solid #e5e7eb" : undefined,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<AlignmentSubmenu editor={editor} />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconBoxMargin size={16} />}
|
||||
onClick={() => editor.chain().focus().mergeCells().run()}
|
||||
disabled={!editor.can().mergeCells()}
|
||||
>
|
||||
{t("Merge cells")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSquareToggle size={16} />}
|
||||
onClick={() => editor.chain().focus().splitCell().run()}
|
||||
disabled={!editor.can().splitCell()}
|
||||
>
|
||||
{t("Split cell")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTableRow size={16} />}
|
||||
onClick={() => editor.chain().focus().toggleHeaderCell().run()}
|
||||
>
|
||||
{t("Toggle header cell")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconColumnInsertRight size={16} />}
|
||||
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
||||
>
|
||||
{t("Add column right")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRowInsertBottom size={16} />}
|
||||
onClick={() => editor.chain().focus().addRowAfter().run()}
|
||||
>
|
||||
{t("Add row below")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearCell}>
|
||||
{t("Clear cell")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumnRemove size={16} />}
|
||||
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||
>
|
||||
{t("Delete column")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRowRemove size={16} />}
|
||||
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||
>
|
||||
{t("Delete row")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { ColorSwatch, Menu } from "@mantine/core";
|
||||
import { TABLE_COLORS } from "../../table-background-color";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconColumnInsertLeft,
|
||||
IconColumnInsertRight,
|
||||
IconColumnRemove,
|
||||
IconEraser,
|
||||
IconPalette,
|
||||
IconSortAscendingLetters,
|
||||
IconSortDescendingLetters,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
|
||||
import { useTableClear } from "../hooks/use-table-clear";
|
||||
import { useTableSort } from "../hooks/use-table-sort";
|
||||
import { AlignmentSubmenu } from "./alignment-submenu";
|
||||
|
||||
interface ColumnHandleMenuProps {
|
||||
editor: Editor;
|
||||
index: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
export const ColumnHandleMenu = React.memo(function ColumnHandleMenu({
|
||||
editor,
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
}: ColumnHandleMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const moveLeft = useTableMoveRowColumn(editor, "col", index, "left", tableNode, tablePos);
|
||||
const moveRight = useTableMoveRowColumn(editor, "col", index, "right", tableNode, tablePos);
|
||||
const clearCol = useTableClear(editor, tableNode, tablePos, {
|
||||
kind: "col",
|
||||
index,
|
||||
});
|
||||
|
||||
const setBackground = (color: string, name: string) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? name : null,
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? name : null,
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const sortAsc = useTableSort({
|
||||
editor,
|
||||
orientation: "col",
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
direction: "asc",
|
||||
});
|
||||
const sortDesc = useTableSort({
|
||||
editor,
|
||||
orientation: "col",
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<IconSortAscendingLetters size={16} />}
|
||||
onClick={sortAsc.handleSort}
|
||||
disabled={!sortAsc.canSort}
|
||||
>
|
||||
{t("Sort A → Z")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSortDescendingLetters size={16} />}
|
||||
onClick={sortDesc.handleSort}
|
||||
disabled={!sortDesc.canSort}
|
||||
>
|
||||
{t("Sort Z → A")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Sub position="right-start">
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
|
||||
{t("Background color")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
|
||||
{TABLE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => setBackground(c.color, c.name)}
|
||||
aria-label={t(c.name)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<ColorSwatch
|
||||
color={c.color || "#ffffff"}
|
||||
size={22}
|
||||
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<AlignmentSubmenu editor={editor} />
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconColumnInsertLeft size={16} />}
|
||||
onClick={() => editor.chain().focus().addColumnBefore().run()}
|
||||
>
|
||||
{t("Add column left")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumnInsertRight size={16} />}
|
||||
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
||||
>
|
||||
{t("Add column right")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconEraser size={16} />}
|
||||
onClick={clearCol}
|
||||
>
|
||||
{t("Clear cells")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconColumnRemove size={16} />}
|
||||
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||
>
|
||||
{t("Delete column")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={moveLeft.handleMove}
|
||||
disabled={!moveLeft.canMove}
|
||||
>
|
||||
{t("Move column left")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
onClick={moveRight.handleMove}
|
||||
disabled={!moveRight.canMove}
|
||||
>
|
||||
{t("Move column right")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { ColorSwatch, Menu } from "@mantine/core";
|
||||
import { TABLE_COLORS } from "../../table-background-color";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconEraser,
|
||||
IconPalette,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconRowRemove,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTableMoveRowColumn } from "../hooks/use-table-move-row-column";
|
||||
import { useTableClear } from "../hooks/use-table-clear";
|
||||
import { AlignmentSubmenu } from "./alignment-submenu";
|
||||
|
||||
interface RowHandleMenuProps {
|
||||
editor: Editor;
|
||||
index: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
export const RowHandleMenu = React.memo(function RowHandleMenu({
|
||||
editor,
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
}: RowHandleMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setBackground = (color: string, name: string) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? name : null,
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
backgroundColor: color || null,
|
||||
backgroundColorName: color ? name : null,
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const moveUp = useTableMoveRowColumn(editor, "row", index, "up", tableNode, tablePos);
|
||||
const moveDown = useTableMoveRowColumn(editor, "row", index, "down", tableNode, tablePos);
|
||||
const clearRow = useTableClear(editor, tableNode, tablePos, {
|
||||
kind: "row",
|
||||
index,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Sub position="right-start">
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconPalette size={16} />}>
|
||||
{t("Background color")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, padding: 8 }}>
|
||||
{TABLE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => setBackground(c.color, c.name)}
|
||||
aria-label={t(c.name)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<ColorSwatch
|
||||
color={c.color || "#ffffff"}
|
||||
size={22}
|
||||
style={{ border: c.color === "" ? "1px solid #e5e7eb" : undefined }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<AlignmentSubmenu editor={editor} />
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconRowInsertTop size={16} />}
|
||||
onClick={() => editor.chain().focus().addRowBefore().run()}
|
||||
>
|
||||
{t("Add row above")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRowInsertBottom size={16} />}
|
||||
onClick={() => editor.chain().focus().addRowAfter().run()}
|
||||
>
|
||||
{t("Add row below")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconEraser size={16} />} onClick={clearRow}>
|
||||
{t("Clear cells")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRowRemove size={16} />}
|
||||
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||
>
|
||||
{t("Delete row")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowUp size={16} />}
|
||||
onClick={moveUp.handleMove}
|
||||
disabled={!moveUp.canMove}
|
||||
>
|
||||
{t("Move row up")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowDown size={16} />}
|
||||
onClick={moveDown.handleMove}
|
||||
disabled={!moveDown.canMove}
|
||||
>
|
||||
{t("Move row down")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { useFloating, offset, autoUpdate, hide } from "@floating-ui/react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
|
||||
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
|
||||
import { RowHandleMenu } from "./menus/row-handle-menu";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface RowHandleProps {
|
||||
editor: Editor;
|
||||
index: number;
|
||||
anchorPos: number;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
}
|
||||
|
||||
export const RowHandle = React.memo(function RowHandle({
|
||||
editor,
|
||||
index,
|
||||
anchorPos,
|
||||
tableNode,
|
||||
tablePos,
|
||||
}: RowHandleProps) {
|
||||
const { t } = useTranslation();
|
||||
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
|
||||
// so the handle div stays mounted across stale-anchor renders, otherwise
|
||||
// pragmatic-dnd silently aborts an in-flight drag.
|
||||
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||
useEffect(() => {
|
||||
if (lookupCellDom && lookupCellDom !== lastCellDomRef.current) {
|
||||
lastCellDomRef.current = lookupCellDom;
|
||||
setCellDom(lookupCellDom);
|
||||
}
|
||||
}, [lookupCellDom]);
|
||||
|
||||
const [handleEl, setHandleEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||
placement: "left",
|
||||
middleware: [offset(-4), hide()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const isReferenceHidden = !!middlewareData.hide?.referenceHidden;
|
||||
|
||||
useEffect(() => {
|
||||
refs.setReference(cellDom);
|
||||
}, [cellDom, refs]);
|
||||
|
||||
const wrapper = cellDom?.closest<HTMLElement>(".tableWrapper") ?? null;
|
||||
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
const closeMenu = useCallback(() => setMenuOpened(false), []);
|
||||
useTableHandleDrag(editor, "row", handleEl, wrapper, closeMenu);
|
||||
|
||||
const { onOpen, onClose } = useColumnRowMenuLifecycle({
|
||||
editor,
|
||||
orientation: "row",
|
||||
index,
|
||||
tableNode,
|
||||
tablePos,
|
||||
});
|
||||
|
||||
if (!cellDom) return null;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
position="right-start"
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
withinPortal
|
||||
shadow="md"
|
||||
>
|
||||
<Menu.Target>
|
||||
<div
|
||||
ref={(node) => {
|
||||
refs.setFloating(node);
|
||||
setHandleEl(node);
|
||||
}}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
...(isReferenceHidden ? { visibility: "hidden" as const } : {}),
|
||||
}}
|
||||
className={clsx(classes.handle, classes.rowHandle)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("Row actions")}
|
||||
>
|
||||
<span style={{ pointerEvents: "none", display: "inline-flex" }}>
|
||||
<GripIcon />
|
||||
</span>
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<RowHandleMenu
|
||||
editor={editor}
|
||||
index={index}
|
||||
tableNode={tableNode}
|
||||
tablePos={tablePos}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
function GripIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 10 10" width="14" height="14" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M3,2 A1,1 0 1 1 3,0 A1,1 0 0 1 3,2 Z M3,6 A1,1 0 1 1 3,4 A1,1 0 0 1 3,6 Z M3,10 A1,1 0 1 1 3,8 A1,1 0 0 1 3,10 Z M7,2 A1,1 0 1 1 7,0 A1,1 0 0 1 7,2 Z M7,6 A1,1 0 1 1 7,4 A1,1 0 0 1 7,6 Z M7,10 A1,1 0 1 1 7,8 A1,1 0 0 1 7,10 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useTableHandleState } from "./hooks/use-table-handle-state";
|
||||
import { ColumnHandle } from "./column-handle";
|
||||
import { RowHandle } from "./row-handle";
|
||||
import { CellChevron } from "./cell-chevron";
|
||||
|
||||
interface TableHandlesLayerProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
export const TableHandlesLayer = React.memo(function TableHandlesLayer({
|
||||
editor,
|
||||
}: TableHandlesLayerProps) {
|
||||
const state = useTableHandleState(editor);
|
||||
|
||||
if (!editor || !editor.isEditable) return null;
|
||||
if (!state.hoveringCell || !state.tableNode || state.tablePos == null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnHandle
|
||||
editor={editor}
|
||||
index={state.hoveringCell.colIndex}
|
||||
anchorPos={state.hoveringCell.colFirstCellPos}
|
||||
tableNode={state.tableNode!}
|
||||
tablePos={state.tablePos!}
|
||||
/>
|
||||
<RowHandle
|
||||
editor={editor}
|
||||
index={state.hoveringCell.rowIndex}
|
||||
anchorPos={state.hoveringCell.rowFirstCellPos}
|
||||
tableNode={state.tableNode!}
|
||||
tablePos={state.tablePos!}
|
||||
/>
|
||||
<CellChevron
|
||||
editor={editor}
|
||||
cellPos={state.hoveringCell.cellPos}
|
||||
tableNode={state.tableNode!}
|
||||
tablePos={state.tablePos!}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -22,7 +22,7 @@ interface TableBackgroundColorProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
const TABLE_COLORS: TableColorItem[] = [
|
||||
export const TABLE_COLORS: TableColorItem[] = [
|
||||
{ name: "Default", color: "" },
|
||||
{ name: "Blue", color: "#b4d5ff" },
|
||||
{ name: "Green", color: "#acf5d2" },
|
||||
|
||||
@@ -104,12 +104,12 @@ export const TableMenu = React.memo(
|
||||
element.style.zIndex = "99";
|
||||
}}
|
||||
options={{
|
||||
placement: "top",
|
||||
placement: "bottom",
|
||||
offset: {
|
||||
mainAxis: 15,
|
||||
},
|
||||
flip: {
|
||||
fallbackPlacements: ["top", "bottom"],
|
||||
fallbackPlacements: ["bottom", "top"],
|
||||
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
||||
boundary: editor.options.element as HTMLElement,
|
||||
},
|
||||
|
||||
@@ -86,11 +86,11 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
transitionProps={{ transition: "pop" }}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text alignment")} withArrow>
|
||||
<Tooltip label={t("Text align")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Text alignment")}
|
||||
aria-label={t("Text align")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
>
|
||||
<activeItem.icon size={18} />
|
||||
|
||||
Reference in New Issue
Block a user