Compare commits

..

5 Commits

Author SHA1 Message Date
Philipinho 1260d60d38 feat: table enhancement 2026-05-12 20:25:22 +01:00
Philipinho bf1ddd8320 tableview 2026-05-10 19:19:23 +01:00
Philip Okugbe a689cca7a0 feat: page labels/tags (#2188)
* feat: labels (WIP)
* full implementation
2026-05-10 18:14:15 +01:00
Philip Okugbe 537e45bc11 feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
2026-05-09 17:03:08 +01:00
Philip Okugbe bdc369fce0 feat(editor): fixed toolbar preference (#2185)
* feat(editor): fixed toolbar preference

* remove key

* cleanup translation strings

* update axios
2026-05-09 13:27:03 +01:00
95 changed files with 6176 additions and 602 deletions
+2
View File
@@ -10,6 +10,8 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5",
"@casl/react": "^5.0.1",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
@@ -276,6 +276,7 @@
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
"Text alignment": "Text alignment",
"Justify": "Justify",
"Merge cells": "Merge cells",
"Split cell": "Split cell",
@@ -286,6 +287,20 @@
"Add row above": "Add row above",
"Add row below": "Add row below",
"Delete table": "Delete table",
"Add column left": "Add column left",
"Add column right": "Add column right",
"Clear cell": "Clear cell",
"Clear cells": "Clear cells",
"Distribute columns": "Distribute columns",
"Toggle header cell": "Toggle header cell",
"Toggle header column": "Toggle header column",
"Toggle header row": "Toggle header row",
"Move column left": "Move column left",
"Move column right": "Move column right",
"Move row down": "Move row down",
"Move row up": "Move row up",
"Sort A → Z": "Sort A → Z",
"Sort Z → A": "Sort Z → A",
"Info": "Info",
"Note": "Note",
"Success": "Success",
@@ -970,5 +985,35 @@
"Experimental": "Experimental",
"Strikethrough": "Strikethrough",
"Undo": "Undo",
"Redo": "Redo"
"Redo": "Redo",
"Backlinks": "Backlinks",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
"Word count": "Word count",
"Characters": "Characters",
"Incoming links": "Incoming links",
"Outgoing links": "Outgoing links",
"Incoming links ({{count}})": "Incoming links ({{count}})",
"Outgoing links ({{count}})": "Outgoing links ({{count}})",
"No pages link here yet.": "No pages link here yet.",
"This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
"Verified until {{date}}": "Verified until {{date}}",
"Labels": "Labels",
"Add label": "Add label",
"No labels yet": "No labels yet",
"Already added": "Already added",
"Invalid label name": "Invalid label name",
"No matches": "No matches",
"Search or create…": "Search or create…",
"Remove label {{name}}": "Remove label {{name}}",
"Failed to add label": "Failed to add label",
"Failed to remove label": "Failed to remove label",
"No pages with this label": "No pages with this label",
"Pages tagged with this label will appear here.": "Pages tagged with this label will appear here.",
"No pages match your search.": "No pages match your search.",
"Updated {{date}}": "Updated {{date}}",
"Cell actions": "Cell actions",
"Column actions": "Column actions",
"Row actions": "Row actions"
}
+2
View File
@@ -45,6 +45,7 @@ import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
export default function App() {
const { t } = useTranslation();
@@ -92,6 +93,7 @@ export default function App() {
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/labels/:labelName"} element={<LabelPage />} />
<Route path={"/templates"} element={<TemplateList />} />
<Route
path={"/templates/:templateId"}
@@ -8,6 +8,7 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
@@ -30,6 +31,10 @@ export default function Aside() {
component = <AsideChatPanel />;
title = "AI Chat";
break;
case "details":
component = <PageDetailsAside />;
title = "Details";
break;
default:
component = null;
title = null;
@@ -147,7 +147,9 @@ export default function GlobalAppShell({
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
: undefined
: asideTab === "details"
? t("Details")
: undefined
}
>
<Aside />
@@ -10,6 +10,7 @@ export const desktopSidebarAtom = atomWithWebStorage<boolean>(
export const desktopAsideAtom = atom<boolean>(false);
// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
@@ -118,10 +118,20 @@ export function PageVerificationBadge({
if (status === "none" && readOnly) return null;
const tooltipLabel =
status === "verified" && verificationInfo?.expiresAt
? t("Verified until {{date}}", {
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
undefined,
{ month: "long", day: "numeric", year: "numeric" },
),
})
: getStatusLabel(status, t);
return (
<>
{status !== "none" ? (
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
<Group
gap={4}
onClick={open}
@@ -3,7 +3,7 @@
top: calc(var(--app-shell-header-offset, 0rem) + 45px);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
z-index: 50;
z-index: 99;
display: flex;
align-items: center;
background: var(--mantine-color-body);
@@ -28,6 +28,7 @@ export const FixedToolbar: FC = () => {
<>
<div
className={classes.fixedToolbar}
data-fixed-toolbar="true"
role="toolbar"
aria-label="Editor toolbar"
onMouseDown={(e) => e.preventDefault()}
@@ -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,122 @@
import React, { 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;
useTableHandleDrag(editor, "col", handleEl, wrapper);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "col",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
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;
}
}
@@ -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]);
}
@@ -0,0 +1,75 @@
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 in this app.
export function useTableHandleDrag(
editor: Editor,
orientation: "col" | "row",
element: HTMLElement | null,
wrapper: HTMLElement | null,
) {
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 }) => {
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]);
}
@@ -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;
}
@@ -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,117 @@
import React, { 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;
useTableHandleDrag(editor, "row", handleEl, wrapper);
const { onOpen, onClose } = useColumnRowMenuLifecycle({
editor,
orientation: "row",
index,
tableNode,
tablePos,
});
if (!cellDom) return null;
return (
<Menu
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,
},
@@ -60,6 +60,23 @@ function nodeDOMAtCoords(
options: GlobalDragHandleOptions,
view: EditorView,
) {
// Custom nodes (transclusion, …) render via tiptap's React node-view
// renderer, which emits `class="react-renderer node-${name}"` on the
// live wrapper — the `data-type` attribute is for static HTML
// serialization only. Match both so we cover live and parsed DOM.
// Inside a custom node, also match plain `p` so the first paragraph
// (which doesn't match `:not(:first-child)`) still gets its own
// handle; only hovers on the custom node's padding/border fall
// through to the wrapper.
const customSelectors = options.customNodes.flatMap((node) => [
`[data-type=${node}]`,
`.node-${node}`,
]);
const customParagraphSelectors = options.customNodes.flatMap((node) => [
`[data-type=${node}] p`,
`.node-${node} p`,
]);
const selectors = [
"li",
"p:not(:first-child)",
@@ -71,7 +88,13 @@ function nodeDOMAtCoords(
"h4",
"h5",
"h6",
...options.customNodes.map((node) => `[data-type=${node}]`),
// Tables nested in another block (toggle, transclusion, …) have a
// wrapper that isn't a direct child of .ProseMirror, so the
// parent-check below skips it. Match the wrapper explicitly so the
// handle shows up even with empty cells.
".tableWrapper",
...customParagraphSelectors,
...customSelectors,
].join(", ");
return document
.elementsFromPoint(coords.x, coords.y)
@@ -99,6 +122,22 @@ function nodePosAtDOM(
})?.inside;
}
function isCustomNodeDOM(
elem: Element | null | undefined,
options: GlobalDragHandleOptions,
): boolean {
if (!elem) return false;
for (const name of options.customNodes) {
if (
elem.getAttribute("data-type") === name ||
elem.classList.contains(`node-${name}`)
) {
return true;
}
}
return false;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
@@ -137,7 +176,6 @@ export function DragHandlePlugin(
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
@@ -166,14 +204,46 @@ export function DragHandlePlugin(
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === "tableRow"
) {
let $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
const $sel = view.state.doc.resolve(selection.from);
if (isCustomNodeDOM(node, options)) {
// The drag landed on a custom-node container (transclusion etc.).
// Walk up to the matching node so the drag moves the whole
// container, not whatever inner element the click landed on.
const customTypes = new Set(options.customNodes);
for (let d = $sel.depth; d > 0; d--) {
if (customTypes.has($sel.node(d).type.name)) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(d),
);
break;
}
}
} else {
// If the selected node lives inside a table (at any nesting
// depth), promote to the whole table — the global drag handle is
// meant to move the table as a single block, not a row/cell. The
// earlier tableRow-only check only worked when the table sat at
// the doc root; once wrapped in another node (toggle, layout,
// etc.) the selection lands on a cell/paragraph and that check
// never fired.
let tableDepth = -1;
for (let d = $sel.depth; d > 0; d--) {
if ($sel.node(d).type.name === "table") {
tableDepth = d;
break;
}
}
if (tableDepth > 0) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(tableDepth),
);
} else if ((selection as NodeSelection).node.type.isInline) {
// Inline node (e.g. mention): walk up to the parent block.
selection = NodeSelection.create(view.state.doc, $sel.before());
}
}
}
view.dispatch(view.state.tr.setSelection(selection));
@@ -313,6 +383,27 @@ export function DragHandlePlugin(
return;
}
const isCustomNode = isCustomNodeDOM(node, options);
// Custom nodes pin the handle to the inner NodeViewWrapper's top-left:
// the natural anchor sits in transient/empty space outside the visible block.
if (isCustomNode) {
// tiptap React node-views emit an outer `.react-renderer` whose first
// child is the visible NodeViewWrapper; walk to that outer first since
// `node` may be either the outer or an inner element with data-type.
const rendererOuter =
(node.closest(".react-renderer") as HTMLElement | null) ?? node;
const inner =
(rendererOuter.firstElementChild as HTMLElement | null) ??
rendererOuter;
const innerRect = absoluteRect(inner);
if (!dragHandleElement) return;
dragHandleElement.style.left = `${innerRect.left + 4}px`;
dragHandleElement.style.top = `${innerRect.top + 4}px`;
showDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
@@ -328,6 +419,13 @@ export function DragHandlePlugin(
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
// Tables: clear the table's own row-drag handle so the two
// grips don't stack on each other. `nodeDOMAtCoords` returns
// the wrapper for top-level hovers (wrapper is direct child of
// .ProseMirror) and a descendant for deeper hovers — cover both.
if (node.closest(".tableWrapper")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
@@ -45,6 +45,9 @@ import {
SearchAndReplace,
Mention,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
Subpages,
Heading,
Highlight,
@@ -56,6 +59,7 @@ import {
Status,
TransclusionSource,
TransclusionReference,
TableView,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -259,11 +263,16 @@ export const mainExtensions = [
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true,
cellMinWidth: 49,
View: TableView,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
TableHandleCommandsExtension,
TableHeaderPin,
TableReadonlySort,
MathInline.configure({
view: MathInlineView,
}),
@@ -3,14 +3,17 @@ import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
ActionIcon,
Container,
Divider,
Group,
Popover,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -19,6 +22,8 @@ import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import clsx from "clsx";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -101,6 +106,7 @@ function PageByline({
readOnly,
}: PageBylineProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -110,8 +116,8 @@ function PageByline({
<Group
gap="sm"
mb="md"
className="print-hide"
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
className={clsx("print-hide", classes.byline)}
style={{ marginTop: "-0.5em" }}
>
{creator && (
<Popover position="bottom-start" shadow="md" width={280} withArrow>
@@ -173,6 +179,17 @@ function PageByline({
</Popover.Dropdown>
</Popover>
)}
<Tooltip label={t("Details")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Details")}
onClick={() => toggleAside("details")}
>
<IconInfoCircle size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
<PageVerificationBadge readOnly={readOnly} />
</Group>
);
@@ -44,6 +44,7 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
@@ -424,7 +425,7 @@ export default function PageEditor({
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<TableHandlesLayer editor={editor} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
@@ -203,7 +203,8 @@
}
}
.resize-cursor {
&.resize-cursor,
&.resize-cursor * {
cursor: ew-resize;
cursor: col-resize;
}
@@ -9,3 +9,15 @@
}
}
.byline {
padding-left: 3rem;
@media (max-width: $mantine-breakpoint-sm) {
padding-left: 1rem;
}
@media print {
padding-left: 0;
}
}
@@ -15,7 +15,8 @@
}
.table-dnd-drop-indicator {
background-color: #adf;
background-color: var(--mantine-color-blue-5);
z-index: 3;
}
.ProseMirror {
@@ -57,13 +58,14 @@
}
.column-resize-handle {
background-color: #adf;
background-color: var(--mantine-color-blue-5);
bottom: -1px;
position: absolute;
right: -2px;
right: -1px;
pointer-events: none;
top: 0;
width: 4px;
width: 2px;
z-index: 3;
}
.selectedCell:after {
@@ -129,6 +131,139 @@
}
}
/* Header-row pinning. Two CSS paths, picked by `header-pin/controller.ts`:
- native sticky (preferred): wrapper drops its overflow constraint so
`position: sticky` on the row can resolve against the document scroll.
- transform fallback: wrapper keeps `overflow-x: auto` for horizontal
scrolling; the row is positioned imperatively per scroll frame.
`--editor-pin-offset` is published to :root by `pinOffsetWatcher` in
`header-pin/offset.ts`, measured against the lowest fixed surface above
the editor (app shell header, page header, fixed toolbar). */
.tableWrapper.tableWrapperNoOverflow,
.tableWrapper.tableWrapperNoOverflow table {
overflow: visible;
}
.tableWrapper.tableHeaderPinned table tr:first-child {
z-index: 2;
}
.tableWrapper.tableWrapperNoOverflow.tableHeaderPinned table tr:first-child {
position: sticky;
top: var(--editor-pin-offset, 90px);
}
.tableWrapper.tableHeaderPinned:not(.tableWrapperNoOverflow) table tr:first-child {
position: relative;
transform: translateY(var(--table-pin-offset, 0px));
}
@media print {
.tableWrapper.tableHeaderPinned table tr:first-child {
position: static;
transform: none;
}
}
.tableReadonlySortChevron {
/* Anchor to the cell's right edge, vertically centered with the cell
content. The cell content (a <p>) is block-level so an inline chevron
would wrap to a new line; absolute positioning takes it out of flow. */
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
background: light-dark(
rgba(55, 53, 47, 0.08),
rgba(255, 255, 255, 0.08)
);
color: light-dark(
rgba(55, 53, 47, 0.55),
rgba(255, 255, 255, 0.55)
);
user-select: none;
cursor: pointer;
z-index: 1;
/* Hidden by default; revealed on header-cell hover or when this column is
the active sort (see selectors below). */
opacity: 0;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
}
.ProseMirror table th:hover .tableReadonlySortChevron,
.tableReadonlySortChevron[data-sort] {
opacity: 1;
}
.ProseMirror table th:has(.tableReadonlySortChevron) {
padding-right: 30px;
}
.tableReadonlySortChevron:hover {
background: light-dark(
rgba(55, 53, 47, 0.16),
rgba(255, 255, 255, 0.16)
);
}
/* Immediate tooltip on the chevron — same style language as the rest of the
app (small, dark, rounded), unlike the native `title` tooltip which only
appears after a long delay. */
.tableReadonlySortChevron::after {
content: attr(data-tooltip);
position: absolute;
/* Below the chevron — placing it above the cell hits the table's
overflow clipping (the wrapper has `overflow-x: auto` which forces
`overflow-y: auto` per spec). */
top: calc(100% + 6px);
right: 0;
padding: 4px 8px;
border-radius: 4px;
background: var(--mantine-color-dark-7);
color: var(--mantine-color-white);
font-size: 12px;
font-weight: 400;
line-height: 1.4;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
z-index: 10;
}
.tableReadonlySortChevron:hover::after {
opacity: 1;
}
.tableReadonlySortChevron svg {
display: block;
}
.tableReadonlySortChevron[data-sort="asc"],
.tableReadonlySortChevron[data-sort="desc"] {
background: light-dark(
var(--mantine-color-blue-1),
var(--mantine-color-blue-9)
);
color: light-dark(
var(--mantine-color-blue-7),
var(--mantine-color-blue-2)
);
}
.tableReadonlySortChevron[data-sort="asc"] svg {
transform: rotate(180deg);
}
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
@@ -0,0 +1,51 @@
import { Link } from "react-router-dom";
import { useComputedColorScheme } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ILabel } from "@/features/label/types/label.types.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import classes from "@/features/label/label.module.css";
type LabelChipProps = {
label: Pick<ILabel, "id" | "name">;
onRemove?: () => void;
asLink?: boolean;
};
export function LabelChip({ label, onRemove, asLink }: LabelChipProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const c = getLabelColor(label.name, scheme);
const nameNode = asLink ? (
<Link
to={`/labels/${encodeURIComponent(label.name)}`}
className={classes.chipLink}
onClick={(e) => e.stopPropagation()}
>
<span className={classes.chipName}>{label.name}</span>
</Link>
) : (
<span className={classes.chipName}>{label.name}</span>
);
return (
<span className={classes.chip} style={{ background: c.bg, color: c.fg }}>
{nameNode}
{onRemove && (
<button
type="button"
className={classes.chipX}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label={t("Remove label {{name}}", { name: label.name })}
>
<IconX size={12} stroke={2} />
</button>
)}
</span>
);
}
@@ -0,0 +1,29 @@
import { Skeleton } from "@mantine/core";
import classes from "@/features/label/label.module.css";
type LabelPageRowSkeletonProps = {
titleWidth?: number;
metaWidth?: number;
};
export function LabelPageRowSkeleton({
titleWidth = 220,
metaWidth = 180,
}: LabelPageRowSkeletonProps) {
return (
<div className={classes.row} aria-hidden="true">
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
<Skeleton height={18} width={18} radius="sm" />
</div>
<div className={classes.rowBody}>
<Skeleton height={15} width={titleWidth} radius="xs" />
<div className={classes.rowMeta}>
<Skeleton height={18} width={18} radius="sm" />
<Skeleton height={12} width={metaWidth} radius="xs" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import { Link } from "react-router-dom";
import { ThemeIcon, Tooltip } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { ILabelPageItem } from "@/features/label/types/label.types.ts";
import { LabelChip } from "@/features/label/components/label-chip.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { buildPageUrl } from "@/features/page/page.utils";
import { formatLabelListDate } from "@/features/label/utils/format-label-date.ts";
import classes from "@/features/label/label.module.css";
type LabelPageRowProps = {
page: ILabelPageItem;
currentLabelName: string;
};
const MAX_VISIBLE_CHIPS = 3;
export function LabelPageRow({ page, currentLabelName }: LabelPageRowProps) {
const { t } = useTranslation();
const otherLabels = page.labels.filter((l) => l.name !== currentLabelName);
const visibleLabels = otherLabels.slice(0, MAX_VISIBLE_CHIPS);
const hiddenLabels = otherLabels.slice(MAX_VISIBLE_CHIPS);
return (
<Link
to={buildPageUrl(page.space?.slug, page.slugId, page.title ?? undefined)}
className={classes.row}
>
<div className={classes.rowMain}>
<div className={classes.rowIcon}>
{page.icon ? (
<span style={{ fontSize: 16, lineHeight: 1 }}>{page.icon}</span>
) : (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
</div>
<div className={classes.rowBody}>
<div className={classes.rowTitle}>
{page.title || t("Untitled")}
</div>
<div className={classes.rowMeta}>
{page.space && (
<>
<CustomAvatar
name={page.space.name}
avatarUrl={page.space.logo ?? undefined}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={18}
/>
<span>{page.space.name}</span>
<span className={classes.metaDot} aria-hidden="true">
</span>
</>
)}
<span className={classes.rowDate}>
{t("Updated {{date}}", {
date: formatLabelListDate(new Date(page.updatedAt)),
})}
</span>
</div>
{/* {otherLabels.length > 0 && (
<div className={classes.rowChips}>
{visibleLabels.map((label) => (
<LabelChip key={label.id} label={label} asLink />
))}
{hiddenLabels.length > 0 && (
<Tooltip
label={hiddenLabels.map((l) => l.name).join(", ")}
withArrow
openDelay={200}
>
<span className={classes.chipMore}>
+{hiddenLabels.length}
</span>
</Tooltip>
)}
</div>
)} */}
</div>
</div>
</Link>
);
}
@@ -0,0 +1,160 @@
import { useMemo, useRef, useState, KeyboardEvent } from "react";
import clsx from "clsx";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core";
import { ILabel } from "@/features/label/types/label.types.ts";
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import classes from "@/features/label/label.module.css";
type LabelPickerProps = {
applied: ILabel[];
enabled: boolean;
onAdd: (name: string) => void;
onClose: () => void;
};
const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/;
const MAX_LABEL_NAME_LENGTH = 100;
function isValidLabelName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_LABEL_NAME_LENGTH &&
NAME_PATTERN.test(name)
);
}
export function LabelPicker({
applied,
enabled,
onAdd,
onClose,
}: LabelPickerProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const [query, setQuery] = useState("");
const [hover, setHover] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const normalized = normalizeLabelName(query);
const { data } = useWorkspaceLabelsQuery(normalized, enabled);
const appliedNames = useMemo(
() => new Set(applied.map((l) => l.name.toLowerCase())),
[applied],
);
const suggestions = useMemo(() => {
const items = data?.items ?? [];
return items.filter((l) => !appliedNames.has(l.name.toLowerCase()));
}, [data, appliedNames]);
const exact = suggestions.find((l) => l.name === normalized);
const canCreate =
!exact && !appliedNames.has(normalized) && isValidLabelName(normalized);
const total = suggestions.length + (canCreate ? 1 : 0);
const select = (idx: number) => {
if (idx < suggestions.length) {
onAdd(suggestions[idx].name);
} else if (canCreate) {
onAdd(normalized);
}
setQuery("");
setHover(0);
inputRef.current?.focus();
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHover((h) => Math.min(Math.max(total - 1, 0), h + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHover((h) => Math.max(0, h - 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (total === 0) return;
select(hover);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
return (
<div className={classes.popover}>
<div className={classes.popoverSearch}>
<input
ref={inputRef}
type="text"
autoFocus
maxLength={MAX_LABEL_NAME_LENGTH}
placeholder={t("Search or create…")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHover(0);
}}
onKeyDown={onKey}
/>
</div>
<div className={classes.popoverList}>
{total === 0 && (
<div className={classes.popoverEmpty}>
{normalized.length === 0
? t("No labels yet")
: appliedNames.has(normalized)
? t("Already added")
: !isValidLabelName(normalized)
? t("Invalid label name")
: t("No matches")}
</div>
)}
{suggestions.map((s, i) => {
const c = getLabelColor(s.name, scheme);
return (
<button
key={s.id}
type="button"
className={clsx(
classes.popoverItem,
hover === i && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(i)}
onClick={() => select(i)}
>
<span
className={classes.popoverItemDot}
style={{ background: c.dot }}
/>
<span className={classes.popoverItemName}>{s.name}</span>
</button>
);
})}
{canCreate && (
<button
type="button"
className={clsx(
classes.popoverItem,
hover === suggestions.length && classes.popoverItemHover,
)}
onMouseEnter={() => setHover(suggestions.length)}
onClick={() => select(suggestions.length)}
>
<span className={classes.popoverCreatePlus}>
<IconPlus size={12} stroke={2} />
</span>
<span className={classes.popoverItemName}>
{t("Create")} <b>"{normalized}"</b>
</span>
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { useState } from "react";
import clsx from "clsx";
import { Divider, Popover, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { LabelChip } from "@/features/label/components/label-chip.tsx";
import { LabelPicker } from "@/features/label/components/label-picker.tsx";
import {
useAddLabelsMutation,
usePageLabelsQuery,
useRemoveLabelMutation,
} from "@/features/label/queries/label-query.ts";
import classes from "@/features/label/label.module.css";
type LabelsSectionProps = {
pageId: string;
canEdit: boolean;
};
export function LabelsSection({ pageId, canEdit }: LabelsSectionProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { data } = usePageLabelsQuery(pageId);
const addMutation = useAddLabelsMutation(pageId);
const removeMutation = useRemoveLabelMutation(pageId);
const labels = data?.items ?? [];
if (!canEdit && labels.length === 0) {
return null;
}
const handleAdd = (name: string) => {
addMutation.mutate({ pageId, names: [name] });
};
const handleRemove = (labelId: string) => {
removeMutation.mutate({ pageId, labelId });
};
return (
<>
<Divider />
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Labels")}
</Text>
<div className={classes.labelsWrap}>
{labels.map((label) => (
<LabelChip
key={label.id}
label={label}
asLink
onRemove={canEdit ? () => handleRemove(label.id) : undefined}
/>
))}
{canEdit && (
<Popover
opened={open}
onChange={setOpen}
position="bottom-end"
shadow="lg"
withinPortal
offset={6}
>
<Popover.Target>
<button
type="button"
className={clsx(classes.addBtn, open && classes.addBtnOpen)}
onClick={() => setOpen((v) => !v)}
>
<IconPlus size={12} stroke={2} />
<span>
{labels.length === 0 ? t("Add label") : t("Add")}
</span>
</button>
</Popover.Target>
<Popover.Dropdown p={0} className={classes.popover}>
<LabelPicker
applied={labels}
enabled={open}
onAdd={(name) => handleAdd(name)}
onClose={() => setOpen(false)}
/>
</Popover.Dropdown>
</Popover>
)}
</div>
</Stack>
</>
);
}
@@ -0,0 +1,325 @@
.labelsWrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 4px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
user-select: none;
white-space: nowrap;
}
.chipName {
letter-spacing: 0.005em;
}
.chipX {
appearance: none;
border: 0;
background: transparent;
color: currentColor;
width: 18px;
height: 18px;
border-radius: 4px;
margin-right: -4px;
margin-left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
opacity: 0.6;
}
.chipX:hover {
opacity: 1;
background: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12));
}
.addBtn {
appearance: none;
border: 1px dashed
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
background: transparent;
color: var(--mantine-color-dimmed);
height: 24px;
padding: 0 8px;
border-radius: 4px;
font: inherit;
font-size: 12.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition:
background 100ms ease,
border-color 100ms ease,
color 100ms ease;
}
.addBtn:hover {
background: light-dark(rgba(0, 0, 0, 0.03), rgba(255, 255, 255, 0.04));
color: var(--mantine-color-text);
border-color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
}
.addBtnOpen {
background: var(--mantine-color-body);
border-style: solid;
border-color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
color: var(--mantine-color-text);
}
.popover {
width: 240px;
padding: 0;
overflow: hidden;
}
.popoverSearch {
padding: 8px 8px 4px;
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.popoverSearch input {
width: 100%;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
padding: 4px 4px;
color: var(--mantine-color-text);
outline: none;
}
.popoverSearch input::placeholder {
color: var(--mantine-color-placeholder);
}
.popoverList {
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.popoverEmpty {
padding: 12px 8px;
color: var(--mantine-color-dimmed);
font-size: 12.5px;
text-align: center;
}
.popoverItem {
appearance: none;
width: 100%;
border: 0;
background: transparent;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font: inherit;
font-size: 13px;
color: var(--mantine-color-text);
cursor: pointer;
text-align: left;
}
.popoverItemHover {
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
.popoverItemDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.popoverItemName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popoverCreatePlus {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-dimmed);
}
.headerChip {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 22px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.005em;
text-decoration: none;
user-select: none;
transition: filter 100ms ease;
}
.headerChip:hover {
filter: brightness(0.97);
}
.headerDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 12px;
margin: 0 -12px;
border-radius: 8px;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: background-color 80ms ease;
}
.row + .row {
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.row:hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.row:hover + .row,
.row:has(+ .row:hover) {
border-top-color: transparent;
}
.rowMain {
display: flex;
gap: 12px;
min-width: 0;
flex: 1;
}
.rowIcon {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--mantine-color-dimmed);
margin-top: 2px;
}
.rowBody {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.rowTitle {
font-size: 15px;
font-weight: 500;
color: var(--mantine-color-text);
line-height: 1.3;
word-break: break-word;
}
.rowChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chipMore {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 4px;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
color: var(--mantine-color-dimmed);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
user-select: none;
white-space: nowrap;
}
.rowMeta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
color: var(--mantine-color-dimmed);
font-size: 13px;
}
.rowDate {
color: var(--mantine-color-dimmed);
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.metaDot {
font-size: 14px;
line-height: 1;
color: var(--mantine-color-dimmed);
}
.chipLink {
text-decoration: none;
color: inherit;
display: inline-flex;
}
.chipLink:hover {
filter: brightness(0.97);
}
@@ -0,0 +1,157 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
addLabelsToPage,
findPagesByLabel,
getLabelInfo,
getPageLabels,
getWorkspaceLabels,
removeLabelFromPage,
} from "@/features/label/services/label-service.ts";
import {
IAddLabels,
ILabel,
IRemoveLabel,
} from "@/features/label/types/label.types.ts";
import { IPagination } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const PAGE_LABELS_KEY = (pageId: string) => ["page-labels", pageId];
const WORKSPACE_LABELS_KEY = (query?: string) => ["workspace-labels", query ?? ""];
export function usePageLabelsQuery(pageId: string | undefined) {
return useQuery({
queryKey: PAGE_LABELS_KEY(pageId ?? ""),
queryFn: () => getPageLabels({ pageId: pageId as string, limit: 100 }),
enabled: !!pageId,
});
}
export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
return useQuery({
queryKey: WORKSPACE_LABELS_KEY(query),
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled,
staleTime: 30 * 1000,
});
}
export function useAddLabelsMutation(pageId: string | undefined) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<ILabel[], Error, IAddLabels>({
mutationFn: (data) => addLabelsToPage(data),
onSuccess: (added) => {
queryClient.setQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
(cache) => {
if (!cache) return cache;
const existing = new Set(cache.items.map((l) => l.id));
const additions = added.filter((l) => !existing.has(l.id));
if (additions.length === 0) return cache;
return { ...cache, items: [...cache.items, ...additions] };
},
);
queryClient.setQueriesData<IPagination<ILabel>>(
{ queryKey: ["workspace-labels"] },
(cache) => {
if (!cache) return cache;
const existing = new Set(cache.items.map((l) => l.id));
const additions = added.filter((l) => !existing.has(l.id));
if (additions.length === 0) return cache;
return {
...cache,
items: [...cache.items, ...additions].sort((a, b) =>
a.name.localeCompare(b.name),
),
};
},
);
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
},
onError: (error: any) => {
notifications.show({
message: error?.response?.data?.message ?? t("Failed to add label"),
color: "red",
});
},
});
}
export function useRemoveLabelMutation(pageId: string | undefined) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRemoveLabel>({
mutationFn: (data) => removeLabelFromPage(data),
onSuccess: (_data, variables) => {
const cache = queryClient.getQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
);
if (cache) {
queryClient.setQueryData<IPagination<ILabel>>(
PAGE_LABELS_KEY(pageId ?? ""),
{
...cache,
items: cache.items.filter((l) => l.id !== variables.labelId),
},
);
}
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] });
},
onError: () => {
notifications.show({
message: t("Failed to remove label"),
color: "red",
});
},
});
}
export function useLabelInfoQuery(name: string, spaceId?: string) {
return useQuery({
queryKey: ["label-info", name, spaceId ?? ""],
queryFn: () => getLabelInfo({ name, type: "page", spaceId }),
enabled: !!name,
placeholderData: keepPreviousData,
});
}
const LABEL_PAGES_LIMIT = 25;
export function useLabelPagesQuery(
name: string,
query: string,
spaceId?: string,
) {
return useInfiniteQuery({
queryKey: ["label-pages", name, query, spaceId ?? ""],
queryFn: ({ pageParam }) =>
findPagesByLabel({
name,
query,
spaceId,
cursor: pageParam,
limit: LABEL_PAGES_LIMIT,
}),
enabled: !!name,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,55 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types.ts";
import {
IAddLabels,
IFindPagesByLabelParams,
ILabel,
ILabelInfo,
ILabelInfoParams,
ILabelPageItem,
IListLabelsParams,
IPageLabelsParams,
IRemoveLabel,
} from "@/features/label/types/label.types.ts";
export async function getPageLabels(
params: IPageLabelsParams,
): Promise<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/pages/labels", params);
return req.data;
}
export async function getWorkspaceLabels(
params: IListLabelsParams,
): Promise<IPagination<ILabel>> {
const req = await api.post<IPagination<ILabel>>("/labels", params);
return req.data;
}
export async function addLabelsToPage(
data: IAddLabels,
): Promise<ILabel[]> {
const req = await api.post<ILabel[]>("/pages/labels/add", data);
return req.data;
}
export async function removeLabelFromPage(data: IRemoveLabel): Promise<void> {
await api.post("/pages/labels/remove", data);
}
export async function getLabelInfo(
params: ILabelInfoParams,
): Promise<ILabelInfo> {
const req = await api.post<ILabelInfo>("/labels/info", params);
return req.data;
}
export async function findPagesByLabel(
params: IFindPagesByLabelParams,
): Promise<IPagination<ILabelPageItem>> {
const req = await api.post<IPagination<ILabelPageItem>>(
"/labels/pages",
params,
);
return req.data;
}
@@ -0,0 +1,70 @@
import { QueryParams } from "@/lib/types.ts";
export type LabelType = "page" | "space";
export interface ILabel {
id: string;
name: string;
type: LabelType;
workspaceId: string;
createdAt: string;
updatedAt: string;
}
export interface IAddLabels {
pageId: string;
names: string[];
}
export interface IRemoveLabel {
pageId: string;
labelId: string;
}
export interface IPageLabelsParams {
pageId: string;
cursor?: string;
limit?: number;
}
export interface IListLabelsParams {
type: LabelType;
query?: string;
cursor?: string;
limit?: number;
}
export interface ILabelInfo {
name: string;
usageCount: number;
}
export interface ILabelPageItem {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
createdAt: string;
updatedAt: string;
space: {
id: string;
name: string;
slug: string;
logo: string | null;
} | null;
creator: { id: string; name: string; avatarUrl: string | null } | null;
labels: { id: string; name: string }[];
}
export interface IFindPagesByLabelParams extends QueryParams {
labelId?: string;
name?: string;
spaceId?: string;
}
export interface ILabelInfoParams {
name: string;
type: LabelType;
spaceId?: string;
}
@@ -0,0 +1,15 @@
import { format, isThisYear, isToday, isYesterday } from "date-fns";
import i18n from "@/i18n.ts";
export function formatLabelListDate(date: Date): string {
if (isToday(date)) {
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
}
if (isYesterday(date)) {
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
}
if (isThisYear(date)) {
return format(date, "MMM dd");
}
return format(date, "MMM dd, yyyy");
}
@@ -0,0 +1,55 @@
type LabelColor = {
bg: string;
fg: string;
dot: string;
};
const LABEL_PALETTE: Record<string, LabelColor> = {
slate: { bg: "#eef1f5", fg: "#3b475a", dot: "#6b7a90" },
blue: { bg: "#e6f0ff", fg: "#1e4fbf", dot: "#3b82f6" },
green: { bg: "#e3f5ea", fg: "#1f7a47", dot: "#22a05a" },
amber: { bg: "#fbf0d9", fg: "#8a5a00", dot: "#d99c1f" },
red: { bg: "#fde6e6", fg: "#a02b2b", dot: "#dc4a4a" },
purple: { bg: "#efe9fb", fg: "#5a3aa8", dot: "#8b6bd9" },
pink: { bg: "#fce6ee", fg: "#a8336d", dot: "#dc6699" },
teal: { bg: "#daf1ee", fg: "#1f6f6a", dot: "#2fa39a" },
};
const PALETTE_KEYS = Object.keys(LABEL_PALETTE);
const DARK_PALETTE: Record<string, LabelColor> = {
slate: { bg: "#2a3140", fg: "#c8d3e3", dot: "#7e8da8" },
blue: { bg: "#152a52", fg: "#a9c4ff", dot: "#5b9aff" },
green: { bg: "#143b27", fg: "#9ce3b8", dot: "#3ec97c" },
amber: { bg: "#3d2c0e", fg: "#f5cf85", dot: "#e6b34a" },
red: { bg: "#401a1a", fg: "#f1a8a8", dot: "#e26565" },
purple: { bg: "#2a1f4d", fg: "#c8b4f4", dot: "#a48ce6" },
pink: { bg: "#3c1a2a", fg: "#f3a9c9", dot: "#e07ab0" },
teal: { bg: "#103633", fg: "#92d5cf", dot: "#48b8af" },
};
function hashName(name: string): number {
// Per-char accumulation with 31. Note: 31 ≡ -1 (mod 8), so the low bits of
// this hash are highly correlated across short strings — `% 8` would cluster.
let h = 0;
for (let i = 0; i < name.length; i++) {
h = (Math.imul(h, 31) + name.charCodeAt(i)) | 0;
}
// Murmur3 fmix32 finalizer — avalanches high bits into low bits so the
// subsequent `% palette.length` (small power of two) is well-distributed.
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
export function getLabelColor(
name: string,
scheme: "light" | "dark" = "light",
): LabelColor {
const key = PALETTE_KEYS[hashName(name) % PALETTE_KEYS.length];
const palette = scheme === "dark" ? DARK_PALETTE : LABEL_PALETTE;
return palette[key];
}
@@ -0,0 +1,3 @@
export function normalizeLabelName(name: string): string {
return name.trim().replace(/\s+/g, "-").toLowerCase();
}
@@ -0,0 +1,113 @@
import {
Button,
Center,
Group,
Loader,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useBacklinksQuery } from "@/features/page-details/queries/backlinks-query.ts";
import {
BacklinkDirection,
IBacklinkPageItem,
} from "@/features/page-details/types/backlink.types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
interface BacklinksListProps {
pageId: string;
direction: BacklinkDirection;
enabled: boolean;
onItemClick: () => void;
}
export function BacklinksList({
pageId,
direction,
enabled,
onItemClick,
}: BacklinksListProps) {
const { t } = useTranslation();
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useBacklinksQuery(pageId, direction, enabled);
if (!enabled) return null;
if (isLoading) {
return (
<Center py="sm">
<Loader size="sm" />
</Center>
);
}
const items: IBacklinkPageItem[] =
data?.pages.flatMap((page) => page.items) ?? [];
if (items.length === 0) {
return (
<Text c="dimmed" size="sm" py="md">
{direction === "incoming"
? t("No pages link here yet.")
: t("This page doesn't link to other pages yet.")}
</Text>
);
}
const handleClick = (e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
onItemClick();
};
return (
<Stack gap={4}>
{items.map((item) => (
<UnstyledButton
key={item.id}
component={Link}
to={
item.space?.slug
? buildPageUrl(
item.space.slug,
item.slugId,
item.title ?? undefined,
)
: "#"
}
onClick={handleClick}
style={{ padding: "8px 4px", borderRadius: 4, userSelect: "none" }}
>
<Group gap="xs" wrap="nowrap">
{getPageIcon(item.icon ?? "")}
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{item.title || t("Untitled")}
</Text>
{item.space?.name && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.space.name}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
))}
{hasNextPage && (
<Button
variant="subtle"
size="xs"
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
mt="xs"
>
{t("Load more")}
</Button>
)}
</Stack>
);
}
@@ -0,0 +1,62 @@
import { Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
import { BacklinksList } from "./backlinks-list";
interface BacklinksModalProps {
pageId: string;
opened: boolean;
onClose: () => void;
}
export function BacklinksModal({
pageId,
opened,
onClose,
}: BacklinksModalProps) {
const { t } = useTranslation();
const { data: counts } = useBacklinksCountQuery(pageId);
return (
<Modal.Root opened={opened} onClose={onClose} size={640} yOffset="10vh">
<Modal.Overlay />
<Modal.Content>
<Modal.Header>
<Modal.Title fw={500}>{t("Backlinks")}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Stack gap="lg">
<Stack gap="xs">
<Text size="sm" fw={500} c="dimmed">
{t("Incoming links ({{count}})", {
count: counts?.incoming ?? 0,
})}
</Text>
<BacklinksList
pageId={pageId}
direction="incoming"
enabled={opened}
onItemClick={onClose}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500} c="dimmed">
{t("Outgoing links ({{count}})", {
count: counts?.outgoing ?? 0,
})}
</Text>
<BacklinksList
pageId={pageId}
direction="outgoing"
enabled={opened}
onItemClick={onClose}
/>
</Stack>
</Stack>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -0,0 +1,239 @@
import {
Divider,
Group,
Skeleton,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { useAtomValue } from "jotai";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
import { BacklinksModal } from "./backlinks-modal";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
export function PageDetailsAside() {
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const pageEditor = useAtomValue(pageEditorAtom);
const { data: counts, isLoading: countsLoading } = useBacklinksCountQuery(page?.id);
const [modalOpened, { open: openModal, close: closeModal }] =
useDisclosure(false);
if (!page) return null;
const wordCount: number =
pageEditor?.storage?.characterCount?.words?.() ?? 0;
const characterCount: number =
pageEditor?.storage?.characterCount?.characters?.() ?? 0;
return (
<>
<Stack gap="md">
<PeopleSection
creator={page.creator}
lastUpdatedBy={page.lastUpdatedBy}
/>
<Divider />
<StatsSection
wordCount={wordCount}
characterCount={characterCount}
createdAt={page.createdAt}
updatedAt={page.updatedAt}
/>
<Divider />
<BacklinksSection
incomingCount={counts?.incoming ?? 0}
outgoingCount={counts?.outgoing ?? 0}
isLoading={countsLoading}
onClick={openModal}
/>
<LabelsSection
pageId={page.id}
canEdit={page.permissions?.canEdit ?? false}
/>
</Stack>
<BacklinksModal
pageId={page.id}
opened={modalOpened}
onClose={closeModal}
/>
</>
);
}
function PeopleSection({
creator,
lastUpdatedBy,
}: {
creator: { id: string; name: string; avatarUrl: string } | null;
lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<PersonRow label={t("Created by")} person={creator} />
<PersonRow label={t("Last updated by")} person={lastUpdatedBy} />
</Stack>
);
}
function PersonRow({
label,
person,
}: {
label: string;
person: { id: string; name: string; avatarUrl: string } | null;
}) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
{person ? (
<Group gap={6} wrap="nowrap">
<CustomAvatar
avatarUrl={person.avatarUrl}
name={person.name}
size={20}
radius="xl"
/>
<Text size="sm" lineClamp={1}>
{person.name}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Group>
);
}
function StatsSection({
wordCount,
characterCount,
createdAt,
updatedAt,
}: {
wordCount: number;
characterCount: number;
createdAt: Date | string;
updatedAt: Date | string;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Stats")}
</Text>
<StatRow label={t("Word count")} value={String(wordCount)} />
<StatRow label={t("Characters")} value={String(characterCount)} />
<StatRow
label={t("Created")}
value={formattedDate(new Date(createdAt))}
/>
<StatRow
label={t("Last updated")}
value={timeAgo(new Date(updatedAt))}
/>
</Stack>
);
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm">{value}</Text>
</Group>
);
}
function BacklinksSection({
incomingCount,
outgoingCount,
isLoading,
onClick,
}: {
incomingCount: number;
outgoingCount: number;
isLoading: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Text size="xs" fw={500} c="dimmed">
{t("Backlinks")}
</Text>
<BacklinksRow
label={t("Incoming links")}
count={incomingCount}
isLoading={isLoading}
onClick={onClick}
/>
<BacklinksRow
label={t("Outgoing links")}
count={outgoingCount}
isLoading={isLoading}
onClick={onClick}
/>
</Stack>
);
}
function BacklinksRow({
label,
count,
isLoading,
onClick,
}: {
label: string;
count: number;
isLoading: boolean;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
style={{
padding: "4px 4px",
borderRadius: 4,
}}
>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Group gap={6} wrap="nowrap">
{isLoading ? (
<Skeleton height={18} width={20} />
) : (
<Text size="sm">{count}</Text>
)}
<IconChevronRight size={16} stroke={2} color="var(--mantine-color-dimmed)" />
</Group>
</Group>
</UnstyledButton>
);
}
@@ -0,0 +1,45 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import {
getBacklinks,
getBacklinksCount,
} from "@/features/page-details/services/backlinks-service.ts";
import {
BacklinkDirection,
IBacklinkCount,
} from "@/features/page-details/types/backlink.types.ts";
const BACKLINKS_STALE_TIME = 30 * 1000;
const BACKLINKS_PAGE_LIMIT = 100;
export function useBacklinksCountQuery(pageId: string | undefined) {
return useQuery<IBacklinkCount>({
queryKey: ["backlinks-count", pageId],
queryFn: () => getBacklinksCount(pageId as string),
enabled: !!pageId,
staleTime: BACKLINKS_STALE_TIME,
});
}
export function useBacklinksQuery(
pageId: string | undefined,
direction: BacklinkDirection,
enabled: boolean,
) {
return useInfiniteQuery({
queryKey: ["backlinks", pageId, direction],
queryFn: ({ pageParam }) =>
getBacklinks({
pageId: pageId as string,
direction,
cursor: pageParam,
limit: BACKLINKS_PAGE_LIMIT,
}),
enabled: enabled && !!pageId,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
staleTime: BACKLINKS_STALE_TIME,
});
}
@@ -0,0 +1,26 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types.ts";
import {
IBacklinkCount,
IBacklinkPageItem,
IBacklinksListParams,
} from "@/features/page-details/types/backlink.types.ts";
export async function getBacklinksCount(
pageId: string,
): Promise<IBacklinkCount> {
const req = await api.post<IBacklinkCount>("/pages/backlinks-count", {
pageId,
});
return req.data;
}
export async function getBacklinks(
params: IBacklinksListParams,
): Promise<IPagination<IBacklinkPageItem>> {
const req = await api.post<IPagination<IBacklinkPageItem>>(
"/pages/backlinks",
params,
);
return req.data;
}
@@ -0,0 +1,23 @@
export type BacklinkDirection = "incoming" | "outgoing";
export interface IBacklinkCount {
incoming: number;
outgoing: number;
}
export interface IBacklinkPageItem {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string } | null;
updatedAt: string;
}
export interface IBacklinksListParams {
pageId: string;
direction: BacklinkDirection;
cursor?: string;
limit?: number;
}
@@ -8,7 +8,7 @@ interface Props {
}
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<div className={classes.header} data-page-header="true">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
@@ -1,13 +1,9 @@
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useEffect } from "react";
import {
Button,
Menu,
Text,
TextInput,
Divider,
Badge,
ScrollArea,
Avatar,
Group,
Switch,
getDefaultZIndex,
@@ -16,12 +12,11 @@ import {
IconChevronDown,
IconBuilding,
IconFileDescription,
IconSearch,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css";
@@ -46,32 +41,13 @@ export function SearchSpotlightFilters({
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null,
);
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page");
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({
limit: 100,
query: debouncedSpaceQuery,
});
const selectedSpaceData = useMemo(() => {
if (!spacesData?.items || !selectedSpaceId) return null;
return spacesData.items.find((space) => space.id === selectedSpaceId);
}, [spacesData?.items, selectedSpaceId]);
const availableSpaces = useMemo(() => {
const spaces = spacesData?.items || [];
if (!selectedSpaceId) return spaces;
// Sort to put selected space first
return [...spaces].sort((a, b) => {
if (a.id === selectedSpaceId) return -1;
if (b.id === selectedSpaceId) return 1;
return 0;
});
}, [spacesData?.items, selectedSpaceId]);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const selectedSpaceData = selectedSpaceId
? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null;
useEffect(() => {
if (onFiltersChange) {
@@ -152,86 +128,27 @@ export function SearchSpotlightFilters({
</div>
)}
<Menu
shadow="md"
width={250}
<SpaceFilterMenu
value={selectedSpaceId}
onChange={handleSpaceSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={spaceSearchQuery}
onChange={(e) => setSpaceSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => handleSpaceSelect(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!selectedSpaceId && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{availableSpaces.map((space) => (
<Menu.Item
key={space.id}
onClick={() => handleSpaceSelect(space.id)}
>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{selectedSpaceId === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconBuilding size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedSpaceId
? `${t("Space")}: ${selectedSpaceData?.name || t("Unknown")}`
: `${t("Space")}: ${t("All spaces")}`}
</Button>
</SpaceFilterMenu>
<Menu
shadow="md"
@@ -0,0 +1,121 @@
import { ReactNode, useMemo, useState } from "react";
import {
Avatar,
Divider,
Group,
Menu,
ScrollArea,
Text,
TextInput,
getDefaultZIndex,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
type SpaceFilterMenuProps = {
value: string | null;
onChange: (spaceId: string | null) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
};
export function SpaceFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
}: SpaceFilterMenuProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data: spacesData } = useGetSpacesQuery({
limit: 100,
query: debouncedQuery,
});
const spaces = spacesData?.items ?? [];
const orderedSpaces = useMemo(() => {
if (!value) return spaces;
return [...spaces].sort((a, b) => {
if (a.id === value) return -1;
if (b.id === value) return 1;
return 0;
});
}, [spaces, value]);
return (
<Menu shadow="md" width={width} position={position} zIndex={zIndex}>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a space")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item onClick={() => onChange(null)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={t("All spaces")}
size={20}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("All spaces")}
</Text>
<Text size="xs" c="dimmed">
{t("Search in all your spaces")}
</Text>
</div>
{!value && <IconCheck size={20} />}
</Group>
</Menu.Item>
<Divider my="xs" />
{orderedSpaces.map((space) => (
<Menu.Item key={space.id} onClick={() => onChange(space.id)}>
<Group flex="1" gap="xs">
<Avatar
color="initials"
variant="filled"
name={space.name}
size={20}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{space.name}
</Text>
{value === space.id && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
export const SPACE_FILTER_MENU_MAX_Z = getDefaultZIndex("max");
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Center,
Container,
Group,
Loader,
Stack,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import {
IconChevronDown,
IconLabel,
IconSearch,
} from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { LabelPageRow } from "@/features/label/components/label-page-row.tsx";
import { LabelPageRowSkeleton } from "@/features/label/components/label-page-row-skeleton.tsx";
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css";
export default function LabelPage() {
const { t } = useTranslation();
const { labelName: rawName } = useParams<{ labelName: string }>();
const labelName = normalizeLabelName(decodeURIComponent(rawName ?? ""));
const scheme = useComputedColorScheme("light");
const c = getLabelColor(labelName, scheme);
const [spaceId, setSpaceId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search.trim(), 200);
const activeSpaceId = spaceId ?? undefined;
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaces = spacesData?.items ?? [];
const {
data: pagesData,
isLoading: pagesLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useLabelPagesQuery(labelName, debouncedSearch, activeSpaceId);
const pages = useMemo(
() => pagesData?.pages.flatMap((p) => p.items) ?? [],
[pagesData],
);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "200px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const selectedSpaceName = useMemo(() => {
if (!spaceId) return t("All spaces");
return spaces.find((s) => s.id === spaceId)?.name ?? t("All spaces");
}, [spaceId, spaces, t]);
return (
<>
<Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<Container size={820} py="xl">
<Stack gap="lg">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("Labels")}
{" / "}
<Text component="span" c="bright" fw={500}>
{labelName}
</Text>
</Text>
<Group gap="md" align="center" wrap="nowrap">
<Link
to={`/labels/${encodeURIComponent(labelName)}`}
className={classes.headerChip}
style={{ background: c.bg, color: c.fg }}
>
<span
className={classes.headerDot}
style={{ background: c.dot }}
/>
<span>{labelName}</span>
</Link>
</Group>
</Stack>
<Group gap="sm" wrap="nowrap" align="center">
<TextInput
placeholder={t("Search by title")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
size="sm"
style={{ flex: 1 }}
/>
<SpaceFilterMenu value={spaceId} onChange={setSpaceId}>
<Button
variant="default"
size="sm"
rightSection={<IconChevronDown size={14} />}
>
{selectedSpaceName}
</Button>
</SpaceFilterMenu>
</Group>
{pagesLoading && pages.length === 0 ? (
<div>
<LabelPageRowSkeleton titleWidth={260} metaWidth={170} />
<LabelPageRowSkeleton titleWidth={180} metaWidth={150} />
<LabelPageRowSkeleton titleWidth={220} metaWidth={190} />
<LabelPageRowSkeleton titleWidth={140} metaWidth={140} />
<LabelPageRowSkeleton titleWidth={240} metaWidth={170} />
</div>
) : pages.length > 0 ? (
<div>
{pages.map((page) => (
<LabelPageRow
key={page.id}
page={page}
currentLabelName={labelName}
/>
))}
<div ref={sentinelRef} />
{isFetchingNextPage && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
</div>
) : (
<EmptyState
icon={IconLabel}
title={
debouncedSearch
? t("No matches")
: t("No pages with this label")
}
description={
debouncedSearch
? t("No pages match your search.")
: t("Pages tagged with this label will appear here.")
}
/>
)}
</Stack>
</Container>
</>
);
}
+2
View File
@@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
import { ShareModule } from './share/share.module';
import { LabelModule } from './label/label.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { FavoriteModule } from './favorite/favorite.module';
@@ -39,6 +40,7 @@ import { ClsMiddleware } from 'nestjs-cls';
CaslModule,
PageAccessModule,
ShareModule,
LabelModule,
NotificationModule,
WatcherModule,
SessionModule,
@@ -0,0 +1,84 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { LabelType } from '@docmost/db/repos/label/label.repo';
import { PageIdDto } from '../../page/dto/page.dto';
import { normalizeLabelName } from '../utils';
//TODO: We may support SPACE/TEMPLATE labels in the future
const SUPPORTED_LABEL_TYPES: LabelType[] = [LabelType.PAGE];
export class AddLabelsDto extends PageIdDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(25)
@IsString({ each: true })
@IsNotEmpty({ each: true })
@Transform(({ value }) =>
Array.isArray(value) ? value.map(normalizeLabelName) : value,
)
@MaxLength(100, { each: true })
@Matches(/^[a-z0-9_-][a-z0-9_~-]*$/, {
each: true,
message:
'Label names can only contain letters, numbers, hyphens, underscores, and tildes, and cannot start with a tilde',
})
names: string[];
}
export class RemoveLabelDto extends PageIdDto {
@IsUUID()
labelId: string;
}
export class FindPagesByLabelDto {
@IsOptional()
@IsUUID()
labelId?: string;
@IsOptional()
@IsString()
@Transform(({ value }) =>
typeof value === 'string' ? normalizeLabelName(value) : value,
)
@MaxLength(100)
name?: string;
@IsOptional()
@IsUUID()
spaceId?: string;
}
export class LabelInfoDto {
@IsString()
@IsNotEmpty()
@Transform(({ value }) =>
typeof value === 'string' ? normalizeLabelName(value) : value,
)
@MaxLength(100)
name: string;
@IsString()
@IsIn(SUPPORTED_LABEL_TYPES)
type: LabelType;
@IsOptional()
@IsUUID()
spaceId?: string;
}
export class ListLabelsDto {
@IsString()
@IsIn(SUPPORTED_LABEL_TYPES)
type: LabelType;
}
@@ -0,0 +1,122 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { LabelService } from './label.service';
import {
FindPagesByLabelDto,
LabelInfoDto,
ListLabelsDto,
} from './dto/label.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { emptyCursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@UseGuards(JwtAuthGuard)
@Controller('labels')
export class LabelController {
constructor(
private readonly labelService: LabelService,
private readonly labelRepo: LabelRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('/')
async getLabels(
@Body() dto: ListLabelsDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.labelService.getLabels(
workspace.id,
user.id,
dto.type,
pagination,
);
}
@HttpCode(HttpStatus.OK)
@Post('pages')
async findPagesByLabel(
@Body() dto: FindPagesByLabelDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (dto.spaceId) {
await this.assertCanReadSpace(user, dto.spaceId);
}
let labelId = dto.labelId;
if (!labelId) {
if (!dto.name) {
throw new BadRequestException('labelId or name is required');
}
const label = await this.labelRepo.findByNameAndWorkspace(
dto.name,
workspace.id,
LabelType.PAGE,
);
if (!label) {
return emptyCursorPaginationResult(pagination.limit);
}
labelId = label.id;
} else {
const label = await this.labelRepo.findById(labelId);
if (!label) {
throw new NotFoundException('Label not found');
}
}
return this.labelService.findPagesByLabel(labelId, user.id, {
spaceId: dto.spaceId,
query: pagination.query,
pagination,
});
}
// @HttpCode(HttpStatus.OK)
// @Post('info')
// async getLabelInfo(
// @Body() dto: LabelInfoDto,
// @AuthUser() user: User,
// @AuthWorkspace() workspace: Workspace,
// ) {
// if (dto.spaceId) {
// await this.assertCanReadSpace(user, dto.spaceId);
// }
//
// return this.labelService.getLabelInfo(
// dto.name,
// dto.type,
// workspace.id,
// user.id,
// dto.spaceId,
// );
// }
private async assertCanReadSpace(user: User, spaceId: string) {
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LabelController } from './label.controller';
import { LabelService } from './label.service';
@Module({
controllers: [LabelController],
providers: [LabelService],
exports: [LabelService],
})
export class LabelModule {}
+140
View File
@@ -0,0 +1,140 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Label } from '@docmost/db/types/entity.types';
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { normalizeLabelName } from './utils';
@Injectable()
export class LabelService {
constructor(
private readonly labelRepo: LabelRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async addLabelsToPage(
pageId: string,
names: string[],
workspaceId: string,
): Promise<Label[]> {
const attached: Label[] = [];
await executeTx(this.db, async (trx) => {
for (const name of names) {
const label = await this.labelRepo.findOrCreate(
name.trim(),
workspaceId,
LabelType.PAGE,
trx,
);
await this.labelRepo.addLabelToPage(pageId, label.id, trx);
attached.push(label);
}
});
return attached;
}
async removeLabelFromPage(
pageId: string,
labelId: string,
workspaceId: string,
): Promise<void> {
await executeTx(this.db, async (trx) => {
const label = await this.labelRepo.findById(labelId, trx);
if (!label || label.workspaceId !== workspaceId) {
throw new NotFoundException('Label not found');
}
await this.labelRepo.removeLabelFromPage(
pageId,
labelId,
workspaceId,
trx,
);
const count = await this.labelRepo.getLabelPageCount(
labelId,
workspaceId,
trx,
);
if (count === 0) {
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
}
});
}
async getPageLabels(pageId: string, pagination: PaginationOptions) {
return this.labelRepo.findLabelsByPageId(pageId, pagination);
}
async getLabels(
workspaceId: string,
userId: string,
type: LabelType,
pagination: PaginationOptions,
) {
return this.labelRepo.findLabels(
workspaceId,
userId,
type,
pagination,
);
}
async findPagesByLabel(
labelId: string,
userId: string,
opts: {
spaceId?: string;
query?: string;
pagination: PaginationOptions;
},
) {
const result = await this.labelRepo.findPagesByLabelId(labelId, userId, opts);
if (result.items.length === 0) return result;
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: result.items.map((p) => p.id),
userId,
spaceId: opts.spaceId,
});
const accessible = new Set(accessibleIds);
return {
items: result.items.filter((p) => accessible.has(p.id)),
meta: result.meta,
};
}
async getLabelInfo(
name: string,
type: LabelType,
workspaceId: string,
userId: string,
spaceId?: string,
) {
const normalized = normalizeLabelName(name);
const label = await this.labelRepo.findByNameAndWorkspace(
normalized,
workspaceId,
type,
);
// Uniform response shape.
// We don't want to expose whether the label row exists
const usageCount = label
? await this.labelRepo.getLabelPageCountForUser(
label.id,
userId,
spaceId,
)
: 0;
return {
name: normalized,
usageCount,
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export function normalizeLabelName(name: string): string {
return name.trim().replace(/\s+/g, '-').toLowerCase();
}
@@ -0,0 +1,11 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
import { PageIdDto } from './page.dto';
export type BacklinkDirection = 'incoming' | 'outgoing';
export class BacklinksListDto extends PageIdDto {
@IsString()
@IsNotEmpty()
@IsIn(['incoming', 'outgoing'])
direction: BacklinkDirection;
}
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { BacklinkService } from './services/backlink.service';
import { PageAccessService } from './page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
@@ -38,6 +39,9 @@ import { RecentPageDto } from './dto/recent-page.dto';
import { CreatedByUserDto } from './dto/created-by-user.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
import { BacklinksListDto } from './dto/backlink.dto';
import { LabelService } from '../label/label.service';
import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
import {
jsonToHtml,
jsonToMarkdown,
@@ -58,6 +62,8 @@ export class PageController {
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
private readonly backlinkService: BacklinkService,
private readonly labelService: LabelService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -96,6 +102,100 @@ export class PageController {
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@Post('labels')
async getPageLabels(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return this.labelService.getPageLabels(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('labels/add')
async addPageLabels(
@Body() dto: AddLabelsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
return this.labelService.addLabelsToPage(
page.id,
dto.names,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('labels/remove')
async removePageLabel(
@Body() dto: RemoveLabelDto,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
await this.labelService.removeLabelFromPage(
page.id,
dto.labelId,
page.workspaceId,
);
}
@HttpCode(HttpStatus.OK)
@Post('backlinks-count')
async getBacklinksCount(
@Body() dto: PageIdDto,
@AuthUser() user: User,
): Promise<{ incoming: number; outgoing: number }> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return this.backlinkService.countByPageId(page.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('backlinks')
async getBacklinks(
@Body() dto: BacklinksListDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return this.backlinkService.findByPageId(
page.id,
dto.direction,
user.id,
pagination,
);
}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
+15 -2
View File
@@ -3,15 +3,28 @@ import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
import { BacklinkService } from './services/backlink.service';
import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module';
import { TransclusionModule } from './transclusion/transclusion.module';
import { LabelModule } from '../label/label.module';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
providers: [
PageService,
PageHistoryService,
TrashCleanupService,
BacklinkService,
],
exports: [PageService, PageHistoryService],
imports: [StorageModule, CollaborationModule, WatcherModule, TransclusionModule],
imports: [
StorageModule,
CollaborationModule,
WatcherModule,
TransclusionModule,
LabelModule,
],
})
export class PageModule {}
@@ -0,0 +1,163 @@
import { Test } from '@nestjs/testing';
import { BacklinkService } from './backlink.service';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
describe('BacklinkService.countByPageId', () => {
let service: BacklinkService;
let backlinkRepo: jest.Mocked<BacklinkRepo>;
let permissionRepo: jest.Mocked<PagePermissionRepo>;
const pageId = '00000000-0000-0000-0000-000000000001';
const userId = '00000000-0000-0000-0000-000000000099';
beforeEach(async () => {
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
findRelatedPageIds: jest.fn(),
findPagesByIdsPaginated: jest.fn(),
};
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
filterAccessiblePageIds: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
BacklinkService,
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
],
}).compile();
service = module.get(BacklinkService);
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
permissionRepo = module.get(
PagePermissionRepo,
) as jest.Mocked<PagePermissionRepo>;
});
it('returns post-filter counts for both directions', async () => {
backlinkRepo.findRelatedPageIds.mockImplementation(async (_id, dir) =>
dir === 'incoming' ? ['a', 'b', 'c'] : ['x', 'y'],
);
permissionRepo.filterAccessiblePageIds.mockImplementation(
async ({ pageIds }) =>
pageIds.filter((id) => id !== 'b' && id !== 'y'),
);
const result = await service.countByPageId(pageId, userId);
expect(result).toEqual({ incoming: 2, outgoing: 1 });
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
pageIds: ['a', 'b', 'c'],
userId,
});
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
pageIds: ['x', 'y'],
userId,
});
});
it('skips the permission filter when there are no candidates', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
permissionRepo.filterAccessiblePageIds.mockResolvedValue([]);
const result = await service.countByPageId(pageId, userId);
expect(result).toEqual({ incoming: 0, outgoing: 0 });
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
});
it('passes the userId to findRelatedPageIds so the repo can apply space membership filtering', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
await service.countByPageId(pageId, userId);
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
pageId,
'incoming',
userId,
);
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
pageId,
'outgoing',
userId,
);
});
});
describe('BacklinkService.findByPageId', () => {
let service: BacklinkService;
let backlinkRepo: jest.Mocked<BacklinkRepo>;
let permissionRepo: jest.Mocked<PagePermissionRepo>;
const pageId = '00000000-0000-0000-0000-000000000001';
const userId = '00000000-0000-0000-0000-000000000099';
beforeEach(async () => {
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
findRelatedPageIds: jest.fn(),
findPagesByIdsPaginated: jest.fn(),
};
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
filterAccessiblePageIds: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
BacklinkService,
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
],
}).compile();
service = module.get(BacklinkService);
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
permissionRepo = module.get(
PagePermissionRepo,
) as jest.Mocked<PagePermissionRepo>;
});
it('passes filtered ids through to the paginated repo call', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue(['a', 'b']);
permissionRepo.filterAccessiblePageIds.mockResolvedValue(['a']);
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
items: [],
meta: {
limit: 20,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
} as any);
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
['a'],
expect.objectContaining({ limit: 20 }),
);
});
it('hands an empty list to the repo when there are no accessible ids', async () => {
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
items: [],
meta: {
limit: 20,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
} as any);
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
[],
expect.objectContaining({ limit: 20 }),
);
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
export type BacklinkDirection = 'incoming' | 'outgoing';
@Injectable()
export class BacklinkService {
constructor(
private readonly backlinkRepo: BacklinkRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async countByPageId(
pageId: string,
userId: string,
): Promise<{ incoming: number; outgoing: number }> {
const [incomingIds, outgoingIds] = await Promise.all([
this.accessibleRelatedIds(pageId, 'incoming', userId),
this.accessibleRelatedIds(pageId, 'outgoing', userId),
]);
return { incoming: incomingIds.length, outgoing: outgoingIds.length };
}
async findByPageId(
pageId: string,
direction: BacklinkDirection,
userId: string,
pagination: PaginationOptions,
) {
const accessibleIds = await this.accessibleRelatedIds(
pageId,
direction,
userId,
);
return this.backlinkRepo.findPagesByIdsPaginated(accessibleIds, pagination);
}
private async accessibleRelatedIds(
pageId: string,
direction: BacklinkDirection,
userId: string,
): Promise<string[]> {
const candidateIds = await this.backlinkRepo.findRelatedPageIds(
pageId,
direction,
userId,
);
if (candidateIds.length === 0) return [];
return this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: candidateIds,
userId,
});
}
}
@@ -425,11 +425,7 @@ export class PageService {
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
childPageIds,
trx,
);
await this.pageRepo.updatePages({ spaceId }, childPageIds, trx);
}
if (pageIdsToMove.length > 0) {
@@ -476,9 +472,13 @@ export class PageService {
);
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.watcherService.movePageWatchersToSpace(
pageIdsToMove,
spaceId,
{
trx,
},
);
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIdsToMove,
@@ -858,13 +858,15 @@ export class PageService {
.selectFrom('page_ancestors')
.selectAll('page_ancestors')
.select((eb) =>
eb.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
).as('hasChildren'),
eb
.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
)
.as('hasChildren'),
)
.execute();
@@ -24,6 +24,7 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
@@ -89,6 +90,7 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
TemplateRepo,
PageListener,
],
@@ -113,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers';
ShareRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
TemplateRepo,
],
})
@@ -0,0 +1,59 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('labels')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('page'))
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('labels_workspace_id_type_name_unique')
.on('labels')
.columns(['workspace_id', 'type', 'name'])
.unique()
.execute();
await db.schema
.createTable('page_labels')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('label_id', 'uuid', (col) =>
col.references('labels.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('page_labels_page_id_label_id_unique', [
'page_id',
'label_id',
])
.execute();
await db.schema
.createIndex('page_labels_label_id_idx')
.on('page_labels')
.column('label_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_labels').execute();
await db.schema.dropTable('labels').execute();
}
@@ -7,10 +7,20 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
executeWithCursorPagination,
emptyCursorPaginationResult,
} from '@docmost/db/pagination/cursor-pagination';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@Injectable()
export class BacklinkRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async findById(
backlinkId: string,
@@ -69,4 +79,84 @@ export class BacklinkRepo {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
}
async findRelatedPageIds(
pageId: string,
direction: 'incoming' | 'outgoing',
userId: string,
): Promise<string[]> {
const userSpaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId);
if (direction === 'incoming') {
const rows = await this.db
.selectFrom('backlinks')
.innerJoin('pages', 'pages.id', 'backlinks.sourcePageId')
.select('backlinks.sourcePageId as relatedId')
.where('backlinks.targetPageId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.where('pages.spaceId', 'in', userSpaceIds)
.execute();
return rows.map((r) => r.relatedId);
}
const rows = await this.db
.selectFrom('backlinks')
.innerJoin('pages', 'pages.id', 'backlinks.targetPageId')
.select('backlinks.targetPageId as relatedId')
.where('backlinks.sourcePageId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.where('pages.spaceId', 'in', userSpaceIds)
.execute();
return rows.map((r) => r.relatedId);
}
async findPagesByIdsPaginated(
pageIds: string[],
pagination: PaginationOptions,
) {
if (pageIds.length === 0) {
return emptyCursorPaginationResult<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
updatedAt: Date;
space: { id: string; slug: string; name: string } | null;
}>(pagination.limit);
}
const query = this.db
.selectFrom('pages')
.select((eb) => [
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.spaceId',
'pages.updatedAt',
jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.slug', 'spaces.name'])
.whereRef('spaces.id', '=', 'pages.spaceId'),
).as('space'),
])
.where('pages.deletedAt', 'is', null)
.where('pages.id', 'in', pageIds);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
{ expression: 'pages.id', direction: 'desc', key: 'id' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
}
@@ -0,0 +1,345 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { Label } from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { normalizeLabelName } from '../../../core/label/utils';
export const LabelType = {
PAGE: 'page',
SPACE: 'space',
} as const;
export type LabelType = (typeof LabelType)[keyof typeof LabelType];
@Injectable()
export class LabelRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async findById(
labelId: string,
trx?: KyselyTransaction,
): Promise<Label | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('labels')
.selectAll()
.where('id', '=', labelId)
.executeTakeFirst();
}
async findByNameAndWorkspace(
name: string,
workspaceId: string,
type: LabelType,
trx?: KyselyTransaction,
): Promise<Label | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('labels')
.selectAll()
.where('name', '=', normalizeLabelName(name))
.where('type', '=', type)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findOrCreate(
name: string,
workspaceId: string,
type: LabelType,
trx?: KyselyTransaction,
): Promise<Label> {
const db = dbOrTx(this.db, trx);
const normalizedName = normalizeLabelName(name);
// DO UPDATE (rather than DO NOTHING) so RETURNING always emits a row,
// even on conflict. Avoids a race where a follow-up SELECT could miss a
// row inserted by a concurrent transaction. The set is a no-op write.
return db
.insertInto('labels')
.values({ name: normalizedName, type, workspaceId })
.onConflict((oc) =>
oc
.columns(['name', 'type', 'workspaceId'])
.doUpdateSet({ name: normalizedName }),
)
.returningAll()
.executeTakeFirstOrThrow();
}
async findLabelsByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('labels')
.innerJoin('pageLabels', 'pageLabels.labelId', 'labels.id')
.select([
'labels.id',
'labels.name',
'labels.type',
'labels.createdAt',
'labels.updatedAt',
'labels.workspaceId',
'pageLabels.id as joinId',
])
.where('pageLabels.pageId', '=', pageId)
.where('labels.type', '=', LabelType.PAGE);
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'pageLabels.id', direction: 'asc', key: 'joinId' },
],
parseCursor: (cursor) => ({
joinId: cursor.joinId,
}),
});
// joinId is an internal pagination cursor; don't leak it to callers.
return {
...result,
items: result.items.map(({ joinId: _joinId, ...rest }) => rest),
};
}
async findLabels(
workspaceId: string,
userId: string,
type: LabelType,
pagination: PaginationOptions,
) {
// Label visibility is scoped to space membership: a label surfaces if it
// is attached to any non-deleted page in a space the user belongs to.
// Per-page permission restrictions intentionally do not narrow this
// further — labels are a space-level concept, not a page-level one.
let query = this.db
.selectFrom('labels')
.select(['id', 'name', 'type', 'createdAt', 'updatedAt', 'workspaceId'])
.where('workspaceId', '=', workspaceId)
.where('type', '=', type)
.where(
'id',
'in',
this.db
.selectFrom('pageLabels')
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
.select('pageLabels.labelId')
.where('pages.deletedAt', 'is', null)
.where(
'pages.spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
);
if (pagination.query) {
query = query.where(
'name',
'like',
`%${pagination.query.toLowerCase()}%`,
);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'name', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
name: cursor.name,
id: cursor.id,
}),
});
}
async addLabelToPage(
pageId: string,
labelId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.insertInto('pageLabels')
.values({ pageId, labelId })
.onConflict((oc) => oc.doNothing())
.execute();
}
async removeLabelFromPage(
pageId: string,
labelId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pageLabels')
.where('pageId', '=', pageId)
.where('labelId', '=', labelId)
.where((eb) =>
eb.exists(
eb
.selectFrom('labels')
.select('id')
.whereRef('labels.id', '=', 'pageLabels.labelId')
.where('labels.workspaceId', '=', workspaceId),
),
)
.execute();
}
async getPageLabelCount(
pageId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('pageLabels')
.select((eb) => eb.fn.count('id').as('count'))
.where('pageId', '=', pageId)
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async getLabelPageCount(
labelId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('pageLabels')
.innerJoin('labels', 'labels.id', 'pageLabels.labelId')
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
.where('pageLabels.labelId', '=', labelId)
.where('labels.workspaceId', '=', workspaceId)
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async deleteLabel(
labelId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('labels')
.where('id', '=', labelId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async findPagesByLabelId(
labelId: string,
userId: string,
opts: {
spaceId?: string;
query?: string;
pagination: PaginationOptions;
},
) {
let query = this.db
.selectFrom('pages')
.innerJoin('pageLabels', 'pageLabels.pageId', 'pages.id')
.select((eb) => [
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.spaceId',
'pages.createdAt',
'pages.updatedAt',
jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.whereRef('spaces.id', '=', 'pages.spaceId'),
).as('space'),
jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'pages.creatorId'),
).as('creator'),
jsonArrayFrom(
eb
.selectFrom('labels')
.innerJoin('pageLabels as pl', 'pl.labelId', 'labels.id')
.select(['labels.id', 'labels.name'])
.whereRef('pl.pageId', '=', 'pages.id')
.where('labels.type', '=', LabelType.PAGE)
.orderBy('pl.id', 'asc'),
).as('labels'),
])
.where('pageLabels.labelId', '=', labelId)
.where('pages.deletedAt', 'is', null);
if (opts.spaceId) {
query = query.where('pages.spaceId', '=', opts.spaceId);
} else {
query = query.where(
'pages.spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
);
}
if (opts.query) {
query = query.where('pages.title', 'ilike', `%${opts.query}%`);
}
return executeWithCursorPagination(query, {
perPage: opts.pagination.limit,
cursor: opts.pagination.cursor,
beforeCursor: opts.pagination.beforeCursor,
fields: [
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
{ expression: 'pages.id', direction: 'desc', key: 'id' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
async getLabelPageCountForUser(
labelId: string,
userId: string,
spaceId?: string,
): Promise<number> {
let query = this.db
.selectFrom('pageLabels')
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
.where('pageLabels.labelId', '=', labelId)
.where('pages.deletedAt', 'is', null);
if (spaceId) {
query = query.where('pages.spaceId', '=', spaceId);
} else {
query = query.where(
'pages.spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
);
}
const result = await query.executeTakeFirst();
return Number(result?.count ?? 0);
}
}
+18
View File
@@ -459,6 +459,15 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface Labels {
id: Generated<string>;
name: string;
type: Generated<string>;
workspaceId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
@@ -470,6 +479,13 @@ export interface PageAccess {
updatedAt: Generated<Timestamp>;
}
export interface PageLabels {
id: Generated<string>;
pageId: string;
labelId: string;
createdAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
@@ -588,12 +604,14 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
labels: Labels;
notifications: Notifications;
pageAccess: PageAccess;
pageTransclusionReferences: PageTransclusionReferences;
pageTransclusions: PageTransclusions;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageLabels: PageLabels;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
@@ -5,7 +5,9 @@ import {
Attachments,
Comments,
Groups,
Labels,
Notifications,
PageLabels,
PageAccess as _PageAccess,
PageTransclusions,
PageTransclusionReferences,
@@ -194,6 +196,15 @@ export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Label
export type Label = Selectable<Labels>;
export type InsertableLabel = Insertable<Labels>;
export type UpdatableLabel = Updateable<Omit<Labels, 'id'>>;
// PageLabel
export type PageLabel = Selectable<PageLabels>;
export type InsertablePageLabel = Insertable<PageLabels>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
+1 -1
View File
@@ -3,7 +3,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
@@ -1,76 +0,0 @@
import { DraggingDOMs } from "./utils";
const EDGE_THRESHOLD = 100;
const SCROLL_SPEED = 10;
export class AutoScrollController {
private _autoScrollInterval?: number;
checkYAutoScroll = (clientY: number) => {
const scrollContainer = document.documentElement;
if (clientY < 0 + EDGE_THRESHOLD) {
this._startYAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
} else if (clientY > window.innerHeight - EDGE_THRESHOLD) {
this._startYAutoScroll(scrollContainer!, SCROLL_SPEED);
} else {
this._stopYAutoScroll();
}
}
checkXAutoScroll = (clientX: number, draggingDOMs: DraggingDOMs) => {
const table = draggingDOMs?.table;
if (!table) return;
const scrollContainer = table.closest<HTMLElement>('.tableWrapper');
const editorRect = scrollContainer.getBoundingClientRect();
if (!scrollContainer) return;
if (clientX < editorRect.left + EDGE_THRESHOLD) {
this._startXAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
} else if (clientX > editorRect.right - EDGE_THRESHOLD) {
this._startXAutoScroll(scrollContainer!, SCROLL_SPEED);
} else {
this._stopXAutoScroll();
}
}
stop = () => {
this._stopXAutoScroll();
this._stopYAutoScroll();
}
private _startXAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
this._autoScrollInterval = window.setInterval(() => {
scrollContainer.scrollLeft += speed;
}, 16);
}
private _stopXAutoScroll = () => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
this._autoScrollInterval = undefined;
}
}
private _startYAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
this._autoScrollInterval = window.setInterval(() => {
scrollContainer.scrollTop += speed;
}, 16);
}
private _stopYAutoScroll = () => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
this._autoScrollInterval = undefined;
}
}
}
@@ -1,316 +1,393 @@
import { Editor, Extension } from "@tiptap/core";
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
import { PluginKey, Plugin, PluginSpec, TextSelection, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorProps, EditorView } from "@tiptap/pm/view";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { cellAround } from "@tiptap/pm/tables";
import {
cellInfoFromResolvedCell,
DraggingDOMs,
getDndRelatedDOMs,
getHoveringCell,
HoveringCellInfo,
} from "./utils";
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
import { findTable } from "../utils/query";
import { moveColumn, moveRow } from "../utils";
import { PreviewController } from "./preview/preview-controller";
import { DropIndicatorController } from "./preview/drop-indicator-controller";
import { DragHandleController } from "./handle/drag-handle-controller";
import { EmptyImageController } from "./handle/empty-image-controller";
import { AutoScrollController } from "./auto-scroll-controller";
export const TableDndKey = new PluginKey("table-drag-and-drop");
export interface TableHandleState {
hoveringCell: HoveringCellInfo | null;
tableNode: ProseMirrorNode | null;
tablePos: number | null;
dragging: { orientation: "col" | "row"; index: number } | null;
frozen: boolean;
}
class TableDragHandlePluginSpec implements PluginSpec<void> {
const INITIAL_STATE: TableHandleState = {
hoveringCell: null,
tableNode: null,
tablePos: null,
dragging: null,
frozen: false,
};
export const TableDndKey = new PluginKey<TableHandleState>("table-handles");
class TableHandlePluginSpec implements PluginSpec<TableHandleState> {
key = TableDndKey;
props: EditorProps<Plugin<void>>;
props: EditorProps<Plugin<TableHandleState>>;
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement;
private _hoveringCell?: HoveringCellInfo;
private _disposables: (() => void)[] = [];
private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 };
private _dragging = false;
private _draggingDirection: "col" | "row" = "col";
private _draggingIndex = -1;
private _droppingIndex = -1;
private _draggingDOMs?: DraggingDOMs | undefined;
private _startCoords: { x: number; y: number } = { x: 0, y: 0 };
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
private _dragHandleController: DragHandleController;
private _emptyImageController: EmptyImageController;
private _autoScrollController: AutoScrollController;
private _draggingDOMs?: DraggingDOMs;
private _startCoords = { x: 0, y: 0 };
private _dragging = false;
state = {
init: (): TableHandleState => INITIAL_STATE,
apply: (tr: Transaction, prev: TableHandleState): TableHandleState => {
const meta = tr.getMeta(TableDndKey) as Partial<TableHandleState> | null;
if (!meta) return prev;
let changed = false;
for (const key in meta) {
if (!Object.is(prev[key as keyof TableHandleState], meta[key as keyof TableHandleState])) {
changed = true;
break;
}
}
return changed ? { ...prev, ...meta } : prev;
},
};
constructor(public editor: Editor) {
this.props = {
handleDOMEvents: {
pointerover: this._pointerOver,
pointermove: this._pointerMove,
// Force-unfreeze on any pointerdown that lands on the editor.
// Mantine's `Menu.onClose` doesn't always fire on outside click
// (the dropdown vanishes visually but the callback is skipped),
// which would otherwise leave `frozen=true` permanently.
pointerdown: this._pointerDown,
},
};
this._dragHandleController = new DragHandleController();
this._colDragHandle = this._dragHandleController.colDragHandle;
this._rowDragHandle = this._dragHandleController.rowDragHandle;
this._previewController = new PreviewController();
this._dropIndicatorController = new DropIndicatorController();
this._emptyImageController = new EmptyImageController();
this._autoScrollController = new AutoScrollController();
this._bindDragEvents();
}
view = () => {
const wrapper = this.editor.options.element;
//@ts-ignore
wrapper.appendChild(this._colDragHandle);
//@ts-ignore
wrapper.appendChild(this._rowDragHandle);
//@ts-ignore
// @ts-ignore
wrapper.appendChild(this._previewController.previewRoot);
//@ts-ignore
// @ts-ignore
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
// Track the cursor cell so handles follow keyboard nav and clicks too.
this.editor.on("selectionUpdate", this._onSelectionUpdate);
this._disposables.push(() =>
this.editor.off("selectionUpdate", this._onSelectionUpdate),
);
return {
update: this.update,
destroy: this.destroy,
};
};
update = () => {};
destroy = () => {
if (!this.editor.isDestroyed) return;
this._dragHandleController.destroy();
this._emptyImageController.destroy();
this._previewController.destroy();
this._dropIndicatorController.destroy();
this._autoScrollController.stop();
this._disposables.forEach((disposable) => disposable());
this._disposables.forEach((d) => d());
};
private _pointerOver = (view: EditorView, event: PointerEvent) => {
if (this._dragging) return;
private _pointerDown = (view: EditorView, _event: PointerEvent): boolean => {
const current = TableDndKey.getState(view.state);
if (current?.frozen) this.editor.commands.unfreezeHandles();
return false;
};
private _pointerMove = (view: EditorView, event: PointerEvent) => {
const current = TableDndKey.getState(view.state);
if (current?.frozen || current?.dragging) return;
const resizeState = columnResizingPluginKey.getState(view.state);
if (resizeState?.dragging) return;
// Don't show drag handles in readonly mode
if (!this.editor.isEditable) {
this._dragHandleController.hide();
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
return;
}
const hoveringCell = getHoveringCell(view, event);
this._hoveringCell = hoveringCell;
if (!hoveringCell) {
this._dragHandleController.hide();
} else {
this._dragHandleController.show(this.editor, hoveringCell);
if (hoveringCell) {
if (current?.hoveringCell?.cellPos === hoveringCell.cellPos) return;
this._hoveringCell = hoveringCell;
const $cell = view.state.doc.resolve(hoveringCell.cellPos);
const tableInfo = findTable($cell);
this._dispatchMeta({
hoveringCell,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
// Pointer isn't over a cell but may be transiting toward a handle that
// floats outside the cell — fall back to the selection's cell so the
// handles stay visible.
const $cellPos = cellAround(view.state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
this._hoveringCell = undefined;
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
};
private _onDragColStart = (event: DragEvent) => {
this._onDragStart(event, "col");
private _onSelectionUpdate = () => {
if (!this.editor.isEditable) return;
const current = TableDndKey.getState(this.editor.state);
if (current?.frozen || current?.dragging) return;
const $cellPos = cellAround(this.editor.state.selection.$head);
if (!$cellPos) return;
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
};
private _onDraggingCol = (event: DragEvent) => {
private _dispatchMeta = (patch: Partial<TableHandleState>) => {
const tr = this.editor.state.tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
};
// ---- Public API for the React handle layer ----
// Returns true if the drag was set up successfully.
startDragFromHandle = (
orientation: "col" | "row",
clientX: number,
clientY: number,
): boolean => {
if (!this._hoveringCell) return false;
this._dragging = true;
this._draggingDirection = orientation;
this._startCoords = { x: clientX, y: clientY };
const draggingIndex =
(orientation === "col"
? this._hoveringCell.colIndex
: this._hoveringCell.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell.cellPos,
draggingIndex,
orientation,
);
if (!relatedDoms) {
this._dragging = false;
return false;
}
this._draggingDOMs = relatedDoms;
this._previewController.onDragStart(relatedDoms, draggingIndex, orientation);
this._dropIndicatorController.onDragStart(relatedDoms, orientation);
// Park the selection inside the dragged cell unless it's already in the
// same table. PM auto-maps `selection.from` through concurrent remote
// transactions, so commitDrop can resolve the table even if the doc
// shifted mid-drag — same trick the pre-pragmatic-dnd implementation
// relied on.
const state = this.editor.state;
const currentTable = findTable(state.selection.$from);
const hoverTable = (() => {
try {
return findTable(state.doc.resolve(this._hoveringCell.cellPos));
} catch {
return undefined;
}
})();
const tr = state.tr;
if (
hoverTable &&
(!currentTable || currentTable.pos !== hoverTable.pos)
) {
try {
const $inside = state.doc.resolve(this._hoveringCell.cellPos + 1);
tr.setSelection(TextSelection.near($inside, 1));
} catch {}
}
tr.setMeta(TableDndKey, {
dragging: { orientation, index: draggingIndex },
});
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
return true;
};
updateDragPosition = (clientX: number, clientY: number) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
if (!draggingDOMs || !this._dragging) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(
draggingDOMs,
this._draggingCoords.x,
this._draggingCoords.y,
"col",
);
if (this._draggingDirection === "col") {
this._previewController.onDragging(
draggingDOMs,
clientX,
clientY,
"col",
);
const direction = this._startCoords.x > clientX ? "left" : "right";
const dragOverColumn = getDragOverColumn(draggingDOMs.table, clientX);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, "col");
return;
}
this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs);
const direction =
this._startCoords.x > this._draggingCoords.x ? "left" : "right";
const dragOverColumn = getDragOverColumn(
draggingDOMs.table,
this._draggingCoords.x,
);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, "col");
};
private _onDragRowStart = (event: DragEvent) => {
this._onDragStart(event, "row");
};
private _onDraggingRow = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(
draggingDOMs,
this._draggingCoords.x,
this._draggingCoords.y,
"row",
);
this._autoScrollController.checkYAutoScroll(event.clientY);
const direction =
this._startCoords.y > this._draggingCoords.y ? "up" : "down";
const dragOverRow = getDragOverRow(
draggingDOMs.table,
this._draggingCoords.y,
);
this._previewController.onDragging(draggingDOMs, clientX, clientY, "row");
const direction = this._startCoords.y > clientY ? "up" : "down";
const dragOverRow = getDragOverRow(draggingDOMs.table, clientY);
if (!dragOverRow) return;
const [row, index] = dragOverRow;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(row, direction, "row");
};
private _onDragEnd = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._autoScrollController.stop();
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
};
private _bindDragEvents = () => {
this._colDragHandle.addEventListener("dragstart", this._onDragColStart);
this._disposables.push(() => {
this._colDragHandle.removeEventListener(
"dragstart",
this._onDragColStart,
);
});
this._colDragHandle.addEventListener("dragend", this._onDragEnd);
this._disposables.push(() => {
this._colDragHandle.removeEventListener("dragend", this._onDragEnd);
});
this._rowDragHandle.addEventListener("dragstart", this._onDragRowStart);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener(
"dragstart",
this._onDragRowStart,
);
});
this._rowDragHandle.addEventListener("dragend", this._onDragEnd);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener("dragend", this._onDragEnd);
});
const ownerDocument = this.editor.view.dom?.ownerDocument;
if (ownerDocument) {
// To make `drop` event work, we need to prevent the default behavior of the
// `dragover` event for drop zone. Here we set the whole document as the
// drop zone so that even the mouse moves outside the editor, the `drop`
// event will still be triggered.
ownerDocument.addEventListener("drop", this._onDrop);
ownerDocument.addEventListener("dragover", this._onDrag);
this._disposables.push(() => {
ownerDocument.removeEventListener("drop", this._onDrop);
ownerDocument.removeEventListener("dragover", this._onDrag);
});
}
};
private _onDragStart = (event: DragEvent, type: "col" | "row") => {
const dataTransfer = event.dataTransfer;
if (dataTransfer) {
dataTransfer.effectAllowed = "move";
this._emptyImageController.hideDragImage(dataTransfer);
}
this._dragging = true;
this._draggingDirection = type;
this._startCoords = { x: event.clientX, y: event.clientY };
const draggingIndex =
(type === "col"
? this._hoveringCell?.colIndex
: this._hoveringCell?.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell?.cellPos,
draggingIndex,
type,
);
this._draggingDOMs = relatedDoms;
const index =
type === "col"
? this._hoveringCell?.colIndex
: this._hoveringCell?.rowIndex;
this._previewController.onDragStart(relatedDoms, index, type);
this._dropIndicatorController.onDragStart(relatedDoms, type);
};
private _onDrag = (event: DragEvent) => {
event.preventDefault();
if (!this._dragging) return;
if (this._draggingDirection === "col") {
this._onDraggingCol(event);
} else {
this._onDraggingRow(event);
}
};
private _onDrop = () => {
commitDrop = () => {
if (!this._dragging) return;
const direction = this._draggingDirection;
const from = this._draggingIndex;
const to = this._droppingIndex;
if (from < 0 || to < 0 || from === to) return;
// Use the live (auto-mapped) selection as the table anchor — PM has
// already mapped it through any concurrent remote transactions, so
// it's safe to resolve even if the doc shifted mid-drag.
const tr = this.editor.state.tr;
const pos = this.editor.state.selection.from;
if (direction === "col") {
const canMove = moveColumn({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
});
if (canMove) {
if (moveColumn({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
this.editor.view.dispatch(tr);
}
return;
}
if (direction === "row") {
const canMove = moveRow({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
});
if (canMove) {
this.editor.view.dispatch(tr);
}
return;
if (moveRow({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
this.editor.view.dispatch(tr);
}
};
endDrag = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._draggingDOMs = undefined;
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
this._dispatchMeta({ dragging: null });
};
}
export type { TableHandlePluginSpec };
// Resolve via plugin key, not a module singleton — survives StrictMode / HMR.
export function getTableHandlePluginSpec(
editor: Editor,
): TableHandlePluginSpec | null {
const plugin = TableDndKey.get(editor.state);
if (!plugin) return null;
return plugin.spec as unknown as TableHandlePluginSpec;
}
export const TableDndExtension = Extension.create({
name: "table-drag-and-drop",
addProseMirrorPlugins() {
const editor = this.editor;
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor);
const dragHandlePlugin = new Plugin(dragHandlePluginSpec);
return [dragHandlePlugin];
const spec = new TableHandlePluginSpec(editor);
return [new Plugin(spec)];
},
});
export const TableHandleCommandsExtension = Extension.create({
name: "table-handle-commands",
addCommands() {
return {
freezeHandles:
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(TableDndKey, { frozen: true });
tr.setMeta("addToHistory", false);
}
return true;
},
unfreezeHandles:
() =>
({ tr, state, dispatch }) => {
if (dispatch) {
// Re-sync `hoveringCell` to the cursor's cell as we unfreeze:
// `selectionUpdate` was gated while frozen, so the stored
// hoveringCell may be stale.
const patch: Partial<TableHandleState> = { frozen: false };
const $cellPos = cellAround(state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
const tableInfo = findTable($cellPos);
patch.hoveringCell = cellInfo;
patch.tableNode = tableInfo?.node ?? null;
patch.tablePos = tableInfo?.pos ?? null;
} else {
patch.hoveringCell = null;
patch.tableNode = null;
patch.tablePos = null;
}
tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
}
return true;
},
};
},
});
declare module "@tiptap/core" {
interface Commands<ReturnType> {
tableHandleCommands: {
freezeHandles: () => ReturnType;
unfreezeHandles: () => ReturnType;
};
}
}
@@ -1,105 +0,0 @@
import { Editor } from "@tiptap/core";
import { HoveringCellInfo } from "../utils";
import { computePosition, offset } from "@floating-ui/dom";
export class DragHandleController {
private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement;
constructor() {
this._colDragHandle = this._createDragHandleDom('col');
this._rowDragHandle = this._createDragHandleDom('row');
}
get colDragHandle() {
return this._colDragHandle;
}
get rowDragHandle() {
return this._rowDragHandle;
}
show = (editor: Editor, hoveringCell: HoveringCellInfo) => {
this._showColDragHandle(editor, hoveringCell);
this._showRowDragHandle(editor, hoveringCell);
}
hide = () => {
Object.assign(this._colDragHandle.style, {
display: 'none',
left: '-999px',
top: '-999px',
});
Object.assign(this._rowDragHandle.style, {
display: 'none',
left: '-999px',
top: '-999px',
});
}
destroy = () => {
this._colDragHandle.remove()
this._rowDragHandle.remove()
}
private _createDragHandleDom = (type: 'col' | 'row') => {
const dragHandle = document.createElement('div')
dragHandle.classList.add('drag-handle')
dragHandle.setAttribute('draggable', 'true')
dragHandle.setAttribute('data-direction', type === 'col' ? 'horizontal' : 'vertical')
dragHandle.setAttribute('data-drag-handle', '')
Object.assign(dragHandle.style, {
position: 'absolute',
top: '-999px',
left: '-999px',
display: 'none',
})
return dragHandle;
}
private _showColDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
const referenceCell = editor.view.nodeDOM(hoveringCell.colFirstCellPos);
if (!referenceCell) return;
const yOffset = -1 * parseInt(getComputedStyle(this._colDragHandle).height) / 2;
computePosition(
referenceCell as HTMLElement,
this._colDragHandle,
{
placement: 'top',
middleware: [offset(yOffset)]
}
)
.then(({ x, y }) => {
Object.assign(this._colDragHandle.style, {
display: 'block',
top: `${y}px`,
left: `${x}px`,
});
})
}
private _showRowDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
const referenceCell = editor.view.nodeDOM(hoveringCell.rowFirstCellPos);
if (!referenceCell) return;
const xOffset = -1 * parseInt(getComputedStyle(this._rowDragHandle).width) / 2;
computePosition(
referenceCell as HTMLElement,
this._rowDragHandle,
{
middleware: [offset(xOffset)],
placement: 'left'
}
)
.then(({ x, y}) => {
Object.assign(this._rowDragHandle.style, {
display: 'block',
top: `${y}px`,
left: `${x}px`,
});
})
}
}
@@ -1,21 +0,0 @@
export class EmptyImageController {
private _emptyImage: HTMLImageElement;
constructor() {
this._emptyImage = new Image(1, 1);
this._emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
get emptyImage() {
return this._emptyImage;
}
hideDragImage = (dataTransfer: DataTransfer) => {
dataTransfer.effectAllowed = 'move';
dataTransfer.setDragImage(this._emptyImage, 0, 0);
}
destroy = () => {
this._emptyImage.remove();
}
}
@@ -1 +1,7 @@
export * from './dnd-extension'
export {
TableDndExtension,
TableHandleCommandsExtension,
TableDndKey,
getTableHandlePluginSpec,
} from "./dnd-extension";
export type { TableHandleState, TableHandlePluginSpec } from "./dnd-extension";
@@ -99,4 +99,4 @@ export class DropIndicatorController {
});
}
}
}
@@ -1,4 +1,4 @@
import { computePosition, offset, ReferenceElement } from "@floating-ui/dom";
import { computePosition, offset, shift, ReferenceElement } from "@floating-ui/dom";
import { DraggingDOMs } from "../utils";
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
@@ -23,7 +23,7 @@ export class PreviewController {
onDragStart = (relatedDoms: DraggingDOMs, index: number | undefined, type: 'col' | 'row') => {
this._initPreviewStyle(relatedDoms.table, relatedDoms.cell, type);
createPreviewDOM(relatedDoms.table, this._preview, index, type)
this._initPreviewPosition(relatedDoms.cell, type);
this._initPreviewPosition(relatedDoms.table, relatedDoms.cell, type);
}
onDragEnd = () => {
@@ -32,7 +32,7 @@ export class PreviewController {
}
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
this._updatePreviewPosition(x, y, relatedDoms.cell, type);
this._updatePreviewPosition(x, y, relatedDoms.table, relatedDoms.cell, type);
}
destroy = () => {
@@ -60,7 +60,7 @@ export class PreviewController {
}
}
private _initPreviewPosition(cell: HTMLElement, type: 'col' | 'row') {
private _initPreviewPosition(table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
void computePosition(cell, this._preview, {
placement: type === 'row' ? 'right' : 'bottom',
middleware: [
@@ -70,6 +70,7 @@ export class PreviewController {
}
return -rects.reference.width
}),
shift({ boundary: table, padding: 0 }),
],
}).then(({ x, y }) => {
Object.assign(this._preview.style, {
@@ -79,11 +80,20 @@ export class PreviewController {
});
}
private _updatePreviewPosition(x: number, y: number, cell: HTMLElement, type: 'col' | 'row') {
// Clamp the preview to within the table's bounds via `shift({ boundary })`
// so it can't track the cursor past the table edge. Without the clamp,
// dragging near the viewport edge pushes the preview's `left` (or `top`)
// beyond the document's natural width/height, the browser extends the
// page to contain it, and the auto-scroll plugin then has a wider area
// to keep scrolling into — a feedback loop that grows the page forever.
private _updatePreviewPosition(x: number, y: number, table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
computePosition(
getVirtualElement(cell, x, y),
this._preview,
{ placement: type === 'row' ? 'right' : 'bottom' },
{
placement: type === 'row' ? 'right' : 'bottom',
middleware: [shift({ boundary: table, padding: 0 })],
},
).then(({ x, y }) => {
if (type === 'row') {
Object.assign(this._preview.style, {
+23 -11
View File
@@ -1,4 +1,5 @@
import { cellAround, TableMap } from "@tiptap/pm/tables"
import { ResolvedPos } from "@tiptap/pm/model"
import { EditorView } from "@tiptap/pm/view"
export function getHoveringCell(
@@ -8,19 +9,30 @@ export function getHoveringCell(
const domCell = domCellAround(event.target as HTMLElement | null)
if (!domCell) return
const { left, top, width, height } = domCell.getBoundingClientRect()
const eventPos = view.posAtCoords({
// Use the center coordinates of the cell to ensure we're within the
// selected cell. This prevents potential issues when the mouse is on the
// border of two cells.
left: left + width / 2,
top: top + height / 2,
})
if (!eventPos) return
const $cellPos = cellAround(view.state.doc.resolve(eventPos.pos))
// Resolve directly from the cell DOM rather than via coords. The previous
// center-coords approach broke on tall merged cells — their visual center
// can land in empty space whose closest PM position resolves to an
// adjacent cell. `posAtDOM(td, 0)` is always inside this cell, regardless
// of rowspan/colspan.
let pos: number
try {
pos = view.posAtDOM(domCell, 0)
} catch {
return
}
const $cellPos = cellAround(view.state.doc.resolve(pos))
if (!$cellPos) return
return cellInfoFromResolvedCell($cellPos)
}
/**
* Build HoveringCellInfo from a resolved position whose parent is a
* table cell (i.e. the result of `cellAround` on some inner position).
*/
export function cellInfoFromResolvedCell(
$cellPos: ResolvedPos,
): HoveringCellInfo {
const map = TableMap.get($cellPos.node(-1))
const tableStart = $cellPos.start(-1)
const cellRect = map.findCell($cellPos.pos - tableStart)
@@ -0,0 +1,186 @@
// Per-table header-pin controller: native sticky when table fits its wrapper, transform fallback when it doesn't.
import { computePinTop, pinOffsetWatcher } from './offset';
const WRAPPER_NO_OVERFLOW = 'tableWrapperNoOverflow';
const HEADER_PINNED = 'tableHeaderPinned';
const PIN_OFFSET_VAR = '--table-pin-offset';
type PinMode = 'off' | 'native' | 'fallback';
function firstRowIsAllHeaders(row: HTMLTableRowElement | null): boolean {
if (!row) return false;
const cells = Array.from(row.cells);
return cells.length > 0 && cells.every((c) => c.tagName === 'TH');
}
function isNestedTable(wrapper: HTMLElement): boolean {
return wrapper.closest('table .tableWrapper') !== null;
}
function isLayoutInert(rect: DOMRectReadOnly): boolean {
return rect.width === 0 && rect.height === 0;
}
const fallbackControllers = new Set<TablePinController>();
let fallbackScrollListener: (() => void) | null = null;
let fallbackRafPending = false;
function ensureFallbackListener() {
if (fallbackScrollListener) return;
fallbackScrollListener = () => {
if (fallbackRafPending) return;
fallbackRafPending = true;
requestAnimationFrame(() => {
fallbackRafPending = false;
for (const ctrl of fallbackControllers) ctrl.updateFallbackOffset();
});
};
document.addEventListener('scroll', fallbackScrollListener, {
passive: true,
capture: true,
});
}
function maybeTeardownFallbackListener() {
if (!fallbackScrollListener || fallbackControllers.size > 0) return;
document.removeEventListener('scroll', fallbackScrollListener, {
capture: true,
});
fallbackScrollListener = null;
fallbackRafPending = false;
}
export class TablePinController {
private wrapper: HTMLElement;
private table: HTMLTableElement;
private fitsObserver?: IntersectionObserver;
private mode: PinMode = 'off';
private cachedHeaderRow: HTMLTableRowElement | null = null;
constructor(wrapper: HTMLElement, table: HTMLTableElement) {
this.wrapper = wrapper;
this.table = table;
pinOffsetWatcher.acquire();
this.fitsObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) this.evaluateFit(entry);
},
{ root: this.wrapper, threshold: 1 },
);
this.fitsObserver.observe(this.table);
}
private getHeaderRow(): HTMLTableRowElement | null {
if (this.cachedHeaderRow && this.table.contains(this.cachedHeaderRow)) {
return this.cachedHeaderRow;
}
this.cachedHeaderRow = this.table.querySelector('tr');
return this.cachedHeaderRow;
}
private evaluateFit(entry: IntersectionObserverEntry) {
if (!this.isEligible()) {
this.apply('off');
return;
}
if (isLayoutInert(entry.boundingClientRect)) return;
this.apply(entry.isIntersecting ? 'native' : 'fallback');
}
private isEligible(): boolean {
return (
!isNestedTable(this.wrapper) && firstRowIsAllHeaders(this.getHeaderRow())
);
}
private apply(next: PinMode) {
if (next === this.mode) return;
if (this.mode === 'fallback' && next !== 'fallback') {
fallbackControllers.delete(this);
maybeTeardownFallbackListener();
}
this.mode = next;
const cls = this.wrapper.classList;
if (next === 'off') {
cls.remove(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'native') {
cls.add(HEADER_PINNED);
cls.add(WRAPPER_NO_OVERFLOW);
// Native mode reads --editor-pin-offset from :root; clear stale per-wrapper var from fallback.
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'fallback') {
cls.add(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
fallbackControllers.add(this);
ensureFallbackListener();
// Avoid one stale-frame paint under translateY.
this.updateFallbackOffset();
}
}
updateFallbackOffset() {
const pinTop = computePinTop();
const tableRect = this.table.getBoundingClientRect();
const headerRow = this.getHeaderRow();
if (!headerRow) return;
const rowHeight = headerRow.getBoundingClientRect().height;
const active = tableRect.top < pinTop && tableRect.bottom > pinTop + rowHeight;
if (active) {
const offset = Math.min(pinTop - tableRect.top, tableRect.height - rowHeight);
this.wrapper.style.setProperty(PIN_OFFSET_VAR, `${offset}px`);
} else {
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
}
}
refresh() {
// The header <tr> may have been replaced by a PM transaction; drop
// the cached reference before checking eligibility.
this.cachedHeaderRow = null;
if (!this.isEligible()) {
this.apply('off');
return;
}
if (this.mode === 'off') {
// Eligibility just flipped back on; re-trigger the observer so it
// emits the current intersection state.
this.fitsObserver?.unobserve(this.table);
this.fitsObserver?.observe(this.table);
}
}
destroy() {
this.fitsObserver?.disconnect();
this.fitsObserver = undefined;
this.apply('off');
pinOffsetWatcher.release();
}
}
const controllers = new WeakMap<HTMLElement, TablePinController>();
export function attach(wrapper: HTMLElement) {
if (controllers.has(wrapper)) return;
const table = wrapper.querySelector(':scope > table') as HTMLTableElement | null;
if (!table) return;
controllers.set(wrapper, new TablePinController(wrapper, table));
}
export function detach(wrapper: HTMLElement) {
const ctrl = controllers.get(wrapper);
if (!ctrl) return;
ctrl.destroy();
controllers.delete(wrapper);
}
export function getController(wrapper: HTMLElement): TablePinController | undefined {
return controllers.get(wrapper);
}
@@ -0,0 +1,78 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { attach, detach, getController } from './controller';
const tableHeaderPinKey = new PluginKey('tableHeaderPin');
export const TableHeaderPin = Extension.create({
name: 'tableHeaderPin',
addProseMirrorPlugins() {
let editorRoot: HTMLElement | null = null;
let domObserver: MutationObserver | null = null;
const tracked = new Set<HTMLElement>();
let rafHandle: number | null = null;
const reconcile = () => {
rafHandle = null;
if (!editorRoot) return;
const current = new Set(
editorRoot.querySelectorAll<HTMLElement>('.tableWrapper'),
);
for (const w of tracked) {
if (!current.has(w)) {
detach(w);
tracked.delete(w);
}
}
for (const w of current) {
if (!tracked.has(w)) {
attach(w);
tracked.add(w);
}
}
};
const schedule = () => {
if (rafHandle !== null) return;
rafHandle = requestAnimationFrame(reconcile);
};
return [
new Plugin({
key: tableHeaderPinKey,
view(editorView) {
editorRoot = editorView.dom as HTMLElement;
schedule();
domObserver = new MutationObserver(schedule);
domObserver.observe(editorRoot, { subtree: true, childList: true });
return {
update(view, prevState) {
if (!editorRoot) return;
if (view.state.doc === prevState.doc) return;
editorRoot
.querySelectorAll<HTMLElement>('.tableWrapper')
.forEach((w) => getController(w)?.refresh());
},
destroy() {
if (rafHandle !== null) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
domObserver?.disconnect();
domObserver = null;
for (const w of tracked) detach(w);
tracked.clear();
editorRoot = null;
},
};
},
}),
];
},
});
@@ -0,0 +1 @@
export { TableHeaderPin } from './extension';
@@ -0,0 +1,65 @@
// Pin-offset measurement and watcher used by the table header-pin controller.
// Fallback app-bar height (px) when no fixed surface is mounted; matches global-app-shell.tsx.
const APP_BAR_FALLBACK_HEIGHT = 45;
export const EDITOR_PIN_OFFSET_VAR = '--editor-pin-offset';
// Selectors for fixed surfaces between viewport top and editor content. Use data attributes —
// CSS module classes are build-time hashed and won't match.
const PIN_ANCHOR_SELECTORS = [
'[data-page-header]',
'[data-fixed-toolbar]',
] as const;
export function computePinTop(): number {
let bottom = APP_BAR_FALLBACK_HEIGHT;
for (const sel of PIN_ANCHOR_SELECTORS) {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.height > 0 && rect.bottom > bottom) bottom = rect.bottom;
}
return bottom;
}
// Reference-counted watcher that publishes the editor's top offset to a CSS custom property.
export const pinOffsetWatcher = {
refs: 0,
resizeObserver: null as ResizeObserver | null,
rafPending: false,
lastValue: -1,
acquire() {
if (this.refs++ > 0) return;
this.publish();
const schedule = () => {
if (this.rafPending) return;
this.rafPending = true;
requestAnimationFrame(() => {
this.rafPending = false;
this.publish();
});
};
this.resizeObserver = new ResizeObserver(schedule);
this.resizeObserver.observe(document.body);
},
release() {
if (--this.refs > 0) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
document.documentElement.style.removeProperty(EDITOR_PIN_OFFSET_VAR);
this.lastValue = -1;
},
publish() {
const top = computePinTop();
if (top === this.lastValue) return;
this.lastValue = top;
document.documentElement.style.setProperty(
EDITOR_PIN_OFFSET_VAR,
`${top}px`,
);
},
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
+11 -1
View File
@@ -2,4 +2,14 @@ export * from "./row";
export * from "./cell";
export * from "./header";
export * from "./table";
export * from "./dnd";
export * from "./dnd";
export * from "./table-view";
export * from "./header-pin";
export * from "./table-readonly-sort";
export { moveColumn } from "./utils/move-column";
export type { MoveColumnParams } from "./utils/move-column";
export { moveRow } from "./utils/move-row";
export type { MoveRowParams } from "./utils/move-row";
export { convertTableNodeToArrayOfRows } from "./utils/convert-table-node-to-array-of-rows";
export { convertArrayOfRowsToTableNode } from "./utils/convert-array-of-rows-to-table-node";
export { transpose } from "./utils/transpose";
@@ -0,0 +1,233 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
type SortDirection = 'asc' | 'desc';
type SortState = {
col: number;
direction: SortDirection;
};
const CHEVRON_CLASS = 'tableReadonlySortChevron';
const tableReadonlySortKey = new PluginKey('tableReadonlySort');
const sortStates = new WeakMap<HTMLTableElement, SortState>();
const originalOrders = new WeakMap<HTMLTableElement, HTMLTableRowElement[]>();
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
function getColumnIndex(th: HTMLTableCellElement): number {
const row = th.parentElement as HTMLTableRowElement;
if (!row) return -1;
let col = 0;
for (let i = 0; i < row.cells.length; i++) {
if (row.cells[i] === th) return col;
col += row.cells[i].colSpan ?? 1;
}
return -1;
}
function getHeaderTh(target: EventTarget | null): HTMLTableCellElement | null {
if (!(target instanceof Element)) return null;
const th = target.closest('th') as HTMLTableCellElement | null;
if (!th) return null;
const row = th.parentElement;
if (!row) return null;
const tbody = row.parentElement;
if (!tbody) return null;
const table = tbody.closest('table');
if (!table) return null;
// th must be in the first row of the table (could be in thead or tbody)
const firstRow = table.querySelector('tr');
if (firstRow !== row) return null;
return th;
}
function getCellText(row: HTMLTableRowElement, colIndex: number): string {
let col = 0;
for (let i = 0; i < row.cells.length; i++) {
if (col === colIndex) return row.cells[i].textContent?.trim() ?? '';
col += row.cells[i].colSpan ?? 1;
}
return '';
}
function getOrSaveOriginalOrder(
table: HTMLTableElement,
dataRows: HTMLTableRowElement[],
): HTMLTableRowElement[] {
if (!originalOrders.has(table)) {
originalOrders.set(table, [...dataRows]);
}
return originalOrders.get(table)!;
}
function sortDataRows(
dataRows: HTMLTableRowElement[],
colIndex: number,
direction: SortDirection,
): HTMLTableRowElement[] {
return [...dataRows].sort((a, b) => {
const textA = getCellText(a, colIndex);
const textB = getCellText(b, colIndex);
const emptyA = textA === '';
const emptyB = textB === '';
if (emptyA && emptyB) return 0;
if (emptyA) return 1;
if (emptyB) return -1;
const cmp = collator.compare(textA, textB);
return direction === 'asc' ? cmp : -cmp;
});
}
function applySort(table: HTMLTableElement, colIndex: number): void {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const allRows = Array.from(tbody.querySelectorAll<HTMLTableRowElement>(':scope > tr'));
if (allRows.length === 0) return;
const headerRow = allRows[0];
const dataRows = allRows.slice(1);
if (dataRows.length === 0) return;
const current = sortStates.get(table) ?? null;
const saved = getOrSaveOriginalOrder(table, dataRows);
let next: SortState | null;
if (!current || current.col !== colIndex) {
next = { col: colIndex, direction: 'asc' };
} else if (current.direction === 'asc') {
next = { col: colIndex, direction: 'desc' };
} else {
next = null;
}
if (next === null) {
sortStates.delete(table);
tbody.append(headerRow, ...saved);
} else {
sortStates.set(table, next);
const sorted = sortDataRows(saved, next.col, next.direction);
tbody.append(headerRow, ...sorted);
}
updateChevrons(table);
}
const CHEVRON_SVG =
'<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">' +
'<path d="M2.5 4.5 L6 8 L9.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />' +
'</svg>';
function ensureChevron(th: HTMLTableCellElement): HTMLSpanElement {
let chevron = th.querySelector<HTMLSpanElement>(`.${CHEVRON_CLASS}`);
if (!chevron) {
chevron = document.createElement('span');
chevron.className = CHEVRON_CLASS;
chevron.setAttribute('aria-hidden', 'true');
chevron.innerHTML = CHEVRON_SVG;
th.appendChild(chevron);
}
return chevron;
}
function updateChevrons(table: HTMLTableElement): void {
const firstRow = table.querySelector('tr');
if (!firstRow) return;
const state = sortStates.get(table) ?? null;
let col = 0;
for (let i = 0; i < firstRow.cells.length; i++) {
const cell = firstRow.cells[i];
if (cell.tagName !== 'TH') {
col += cell.colSpan ?? 1;
continue;
}
const chevron = ensureChevron(cell as HTMLTableCellElement);
let label: string;
if (state && state.col === col) {
chevron.setAttribute('data-sort', state.direction);
label = state.direction === 'asc' ? 'Sort descending' : 'Clear sort';
} else {
chevron.removeAttribute('data-sort');
label = 'Sort ascending';
}
chevron.setAttribute('data-tooltip', label);
chevron.setAttribute('aria-label', label);
chevron.title = label;
col += cell.colSpan ?? 1;
}
}
function addChevronsToAllTables(editorRoot: HTMLElement): void {
const tables = editorRoot.querySelectorAll<HTMLTableElement>('table');
tables.forEach((table) => updateChevrons(table));
}
function removeAllChevrons(editorRoot: HTMLElement): void {
editorRoot
.querySelectorAll<HTMLSpanElement>(`.${CHEVRON_CLASS}`)
.forEach((el) => el.remove());
}
export const TableReadonlySort = Extension.create({
name: 'tableReadonlySort',
addProseMirrorPlugins() {
const editor = this.editor;
let editorRoot: HTMLElement | null = null;
const onClick = (event: MouseEvent) => {
if (editor.isEditable) return;
// Only react to clicks on the chevron, not anywhere else in the header
// cell. This lets the user click into a header to select text without
// accidentally triggering a sort.
if (!(event.target instanceof Element)) return;
const chevron = event.target.closest(`.${CHEVRON_CLASS}`);
if (!chevron) return;
const th = getHeaderTh(chevron);
if (!th) return;
const table = th.closest('table') as HTMLTableElement | null;
if (!table) return;
const colIndex = getColumnIndex(th);
if (colIndex < 0) return;
applySort(table, colIndex);
};
return [
new Plugin({
key: tableReadonlySortKey,
view(editorView) {
editorRoot = editorView.dom as HTMLElement;
editorRoot.addEventListener('click', onClick);
if (!editor.isEditable) {
addChevronsToAllTables(editorRoot);
}
return {
update(view) {
const root = view.dom as HTMLElement;
if (!editor.isEditable) {
addChevronsToAllTables(root);
} else {
removeAllChevrons(root);
}
},
destroy() {
if (editorRoot) {
editorRoot.removeEventListener('click', onClick);
removeAllChevrons(editorRoot);
}
},
};
},
}),
];
},
});
@@ -0,0 +1,158 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import type { NodeView, ViewMutationRecord } from '@tiptap/pm/view';
import { getColStyleDeclaration } from './utils/col-style';
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLTableElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number,
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild;
const row = node.firstChild;
if (row !== null) {
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col
? overrideValue
: ((colwidth && colwidth[j]) as number | undefined);
const cssWidth = hasWidth ? `${hasWidth}px` : '';
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
const colElement = document.createElement('col');
const [propertyKey, propertyValue] = getColStyleDeclaration(
cellMinWidth,
hasWidth,
);
colElement.style.setProperty(propertyKey, propertyValue);
colgroup.appendChild(colElement);
} else {
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
const [propertyKey, propertyValue] = getColStyleDeclaration(
cellMinWidth,
hasWidth,
);
(nextDOM as HTMLTableColElement).style.setProperty(
propertyKey,
propertyValue,
);
}
nextDOM = nextDOM.nextSibling;
}
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling;
nextDOM.parentNode?.removeChild(nextDOM);
nextDOM = after;
}
const hasUserWidth =
node.attrs.style &&
typeof node.attrs.style === 'string' &&
/\bwidth\s*:/i.test(node.attrs.style);
if (fixedWidth && !hasUserWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = '';
} else {
table.style.width = '';
table.style.minWidth = `${totalWidth}px`;
}
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
dom: HTMLDivElement;
table: HTMLTableElement;
colgroup: HTMLTableColElement;
contentDOM: HTMLTableSectionElement;
constructor(node: ProseMirrorNode, cellMinWidth: number) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.dom = document.createElement('div');
this.dom.className = 'tableWrapper';
this.table = this.dom.appendChild(document.createElement('table'));
if (node.attrs.style) {
this.table.style.cssText = node.attrs.style;
}
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
updateColumns(node, this.colgroup, this.table, cellMinWidth);
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) return false;
this.node = node;
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
return true;
}
ignoreMutation(mutation: ViewMutationRecord) {
const target = mutation.target as Node;
const isInsideWrapper = this.dom.contains(target);
const isInsideContent = this.contentDOM.contains(target);
if (isInsideWrapper && !isInsideContent) {
if (
mutation.type === 'attributes' ||
mutation.type === 'childList' ||
mutation.type === 'characterData'
) {
return true;
}
}
// Chevron span (.tableReadonlySortChevron) added/removed by sort plugin.
if (mutation.type === 'childList') {
const nodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
];
if (
nodes.some(
(n) =>
n instanceof Element &&
n.classList.contains('tableReadonlySortChevron'),
)
) {
return true;
}
}
return false;
}
}
@@ -1,6 +1,8 @@
import { Table } from "@tiptap/extension-table";
import { Editor } from "@tiptap/core";
import { DOMOutputSpec } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { cellAround } from "@tiptap/pm/tables";
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
@@ -32,9 +34,36 @@ function handleListOutdent(editor: Editor): boolean {
}
export const CustomTable = Table.extend({
addKeyboardShortcuts() {
return {
...this.parent?.(),
"Mod-a": () => {
const { state, view } = this.editor;
const { selection, doc } = state;
const $cellPos = cellAround(selection.$anchor);
if (!$cellPos) return false;
const cellNode = doc.nodeAt($cellPos.pos);
// Empty cells have nothing useful to scope to — let the default
// Mod-a fall through and select the whole doc.
if (!cellNode || !cellNode.textContent) return false;
const from = $cellPos.pos + 1;
const to = $cellPos.pos + cellNode.nodeSize - 1;
if (from >= to) return true;
const nextSel = TextSelection.between(
doc.resolve(from),
doc.resolve(to),
1,
);
if (!nextSel || selection.eq(nextSel)) return true;
view.dispatch(state.tr.setSelection(nextSel));
return true;
},
Tab: () => {
// If we're in a list within a table, handle list indentation
if (isInList(this.editor) && this.editor.isActive("table")) {
@@ -0,0 +1,7 @@
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
if (width) {
return ['width', `${Math.max(width, minWidth)}px`]
}
return ['min-width', `${minWidth}px`]
}
+33
View File
@@ -250,6 +250,12 @@ importers:
apps/client:
dependencies:
'@atlaskit/pragmatic-drag-and-drop':
specifier: ^1.8.1
version: 1.8.1
'@atlaskit/pragmatic-drag-and-drop-auto-scroll':
specifier: ^2.1.5
version: 2.1.5
'@casl/react':
specifier: ^5.0.1
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
@@ -909,6 +915,12 @@ packages:
'@asamuzakjp/css-color@2.8.3':
resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==}
'@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5':
resolution: {integrity: sha512-InLvVhZAHPBfv3CxuG4AfOQuhNJjaFy69YBfodPMWtRFQNQAKa9Yb3vL9Ho6qsD9qKUBuJa4A5k7QddaXQ4Eyw==}
'@atlaskit/pragmatic-drag-and-drop@1.8.1':
resolution: {integrity: sha512-uXWNPpL8n4OmTVbduH7nq8pk8htqGo/prR5cYEE8sVCPJGAUMWn6lzvWTfI+4VCeQvHiDRODVz4YzH06OVAxhw==}
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -5540,6 +5552,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bind-event-listener@3.0.0:
resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -8938,6 +8953,9 @@ packages:
'@types/react-dom':
optional: true
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -10557,6 +10575,17 @@ snapshots:
'@csstools/css-tokenizer': 3.0.3
lru-cache: 10.4.3
'@atlaskit/pragmatic-drag-and-drop-auto-scroll@2.1.5':
dependencies:
'@atlaskit/pragmatic-drag-and-drop': 1.8.1
'@babel/runtime': 7.29.2
'@atlaskit/pragmatic-drag-and-drop@1.8.1':
dependencies:
'@babel/runtime': 7.29.2
bind-event-listener: 3.0.0
raf-schd: 4.0.3
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -15978,6 +16007,8 @@ snapshots:
binary-extensions@2.3.0: {}
bind-event-listener@3.0.0: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
@@ -19987,6 +20018,8 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
raf-schd@4.0.3: {}
range-parser@1.2.1: {}
raw-body@3.0.2: