From aa58e272d68c53177725e2f052ba4c98cee19414 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 31 Aug 2025 09:11:33 +0100 Subject: [PATCH 01/11] fix: exclude deleted pages (#1494) --- apps/server/src/database/repos/page/page.repo.ts | 4 +++- apps/server/src/integrations/export/export.controller.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index c814240a..e577cc43 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -399,6 +399,7 @@ export class PageRepo { ]) .$if(opts?.includeContent, (qb) => qb.select('content')) .where('id', '=', parentPageId) + .where('deletedAt', 'is', null) .unionAll((exp) => exp .selectFrom('pages as p') @@ -413,7 +414,8 @@ export class PageRepo { 'p.workspaceId', ]) .$if(opts?.includeContent, (qb) => qb.select('p.content')) - .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'), + .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') + .where('p.deletedAt', 'is', null), ), ) .selectFrom('page_hierarchy') diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 7ecea668..9d49d108 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -46,7 +46,7 @@ export class ExportController { includeContent: true, }); - if (!page) { + if (!page || page.deletedAt) { throw new NotFoundException('Page not found'); } From 7d1e5bce0dbbf6a863fb219d171549a71039db0c Mon Sep 17 00:00:00 2001 From: Mirone Date: Mon, 1 Sep 2025 02:53:27 +0900 Subject: [PATCH 02/11] feat: table row/column drag and drop (#1467) * chore: add dev container * feat: add drag handle when hovering cell * feat: add column drag and drop * feat: add support for row drag and drop * refactor: extract preview controllers * fix: hover issue * refactor: add handle controller * chore: f * chore: remove log * chore: remove dev files * feat: hide other drop indicators when table dnd working * feat: add auto scroll and bug fix * chore: f * fix: firefox --- .../features/editor/extensions/extensions.ts | 2 + .../src/features/editor/page-editor.tsx | 2 +- .../features/editor/styles/drag-handle.css | 4 + .../src/features/editor/styles/table.css | 20 ++ package.json | 1 + .../lib/table/dnd/auto-scroll-controller.ts | 76 +++++ .../src/lib/table/dnd/calc-drag-over.ts | 44 +++ .../src/lib/table/dnd/dnd-extension.ts | 271 ++++++++++++++++++ .../dnd/handle/drag-handle-controller.ts | 105 +++++++ .../dnd/handle/empty-image-controller.ts | 21 ++ .../editor-ext/src/lib/table/dnd/index.ts | 1 + .../dnd/preview/drop-indicator-controller.ts | 102 +++++++ .../table/dnd/preview/preview-controller.ts | 122 ++++++++ .../lib/table/dnd/preview/render-preview.ts | 37 +++ .../editor-ext/src/lib/table/dnd/utils.ts | 107 +++++++ packages/editor-ext/src/lib/table/index.ts | 1 + .../convert-array-of-rows-to-table-node.ts | 44 +++ .../convert-table-node-to-array-of-rows.ts | 61 ++++ .../lib/table/utils/get-cells-in-column.ts | 36 +++ .../src/lib/table/utils/get-cells-in-row.ts | 36 +++ .../utils/get-selection-range-in-column.ts | 91 ++++++ .../table/utils/get-selection-range-in-row.ts | 89 ++++++ .../editor-ext/src/lib/table/utils/index.ts | 3 + .../src/lib/table/utils/move-column.ts | 81 ++++++ .../table/utils/move-row-in-array-of-rows.ts | 30 ++ .../src/lib/table/utils/move-row.ts | 74 +++++ .../editor-ext/src/lib/table/utils/query.ts | 122 ++++++++ .../src/lib/table/utils/transpose.ts | 29 ++ .../editor-ext/src/lib/table/utils/types.ts | 18 ++ pnpm-lock.yaml | 23 ++ 30 files changed, 1652 insertions(+), 1 deletion(-) create mode 100644 packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/dnd-extension.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/index.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/preview/render-preview.ts create mode 100644 packages/editor-ext/src/lib/table/dnd/utils.ts create mode 100644 packages/editor-ext/src/lib/table/utils/convert-array-of-rows-to-table-node.ts create mode 100644 packages/editor-ext/src/lib/table/utils/convert-table-node-to-array-of-rows.ts create mode 100644 packages/editor-ext/src/lib/table/utils/get-cells-in-column.ts create mode 100644 packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts create mode 100644 packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.ts create mode 100644 packages/editor-ext/src/lib/table/utils/get-selection-range-in-row.ts create mode 100644 packages/editor-ext/src/lib/table/utils/index.ts create mode 100644 packages/editor-ext/src/lib/table/utils/move-column.ts create mode 100644 packages/editor-ext/src/lib/table/utils/move-row-in-array-of-rows.ts create mode 100644 packages/editor-ext/src/lib/table/utils/move-row.ts create mode 100644 packages/editor-ext/src/lib/table/utils/query.ts create mode 100644 packages/editor-ext/src/lib/table/utils/transpose.ts create mode 100644 packages/editor-ext/src/lib/table/utils/types.ts diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 922bcd59..adfed7ed 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -38,6 +38,7 @@ import { Embed, SearchAndReplace, Mention, + TableDndExtension, } from "@docmost/editor-ext"; import { randomElement, @@ -168,6 +169,7 @@ export const mainExtensions = [ TableRow, TableCell, TableHeader, + TableDndExtension, MathInline.configure({ view: MathInlineView, }), diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 5d88b208..44aa403b 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -375,7 +375,7 @@ export default function PageEditor({ } return ( -
+
diff --git a/apps/client/src/features/editor/styles/drag-handle.css b/apps/client/src/features/editor/styles/drag-handle.css index e1d3ae4b..f77c9630 100644 --- a/apps/client/src/features/editor/styles/drag-handle.css +++ b/apps/client/src/features/editor/styles/drag-handle.css @@ -45,6 +45,10 @@ display: none; pointer-events: none; } + + &[data-direction='horizontal'] { + rotate: 90deg; + } } .dark .drag-handle { diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css index d60a299c..9926d0bc 100644 --- a/apps/client/src/features/editor/styles/table.css +++ b/apps/client/src/features/editor/styles/table.css @@ -8,6 +8,16 @@ } } +.table-dnd-preview { + padding: 0; + background-color: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(2px); +} + +.table-dnd-drop-indicator { + background-color: #adf; +} + .ProseMirror { table { border-collapse: collapse; @@ -118,3 +128,13 @@ } } } + +.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) { + .prosemirror-dropcursor-block { + display: none; + } + + .prosemirror-dropcursor-inline { + display: none; + } +} \ No newline at end of file diff --git a/package.json b/package.json index 4b14083e..707c262f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@braintree/sanitize-url": "^7.1.0", "@docmost/editor-ext": "workspace:*", + "@floating-ui/dom": "^1.7.3", "@hocuspocus/extension-redis": "^2.15.2", "@hocuspocus/provider": "^2.15.2", "@hocuspocus/server": "^2.15.2", diff --git a/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts b/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts new file mode 100644 index 00000000..9b8304d5 --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/auto-scroll-controller.ts @@ -0,0 +1,76 @@ +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('.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; + } + } +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts b/packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts new file mode 100644 index 00000000..8bdb68ad --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/calc-drag-over.ts @@ -0,0 +1,44 @@ +function findDragOverElement( + elements: Element[], + pointer: number, + axis: 'x' | 'y', +): [Element, number] | undefined { + const startProp = axis === 'x' ? 'left' : 'top' + const endProp = axis === 'x' ? 'right' : 'bottom' + const lastIndex = elements.length - 1 + + const index = elements.findIndex((el, index) => { + const rect = el.getBoundingClientRect() + const boundaryStart = rect[startProp] + const boundaryEnd = rect[endProp] + + // The pointer is within the boundary of the current element. + if (boundaryStart <= pointer && pointer <= boundaryEnd) return true + // The pointer is beyond the last element. + if (index === lastIndex && pointer > boundaryEnd) return true + // The pointer is before the first element. + if (index === 0 && pointer < boundaryStart) return true + + return false + }) + + return index >= 0 ? [elements[index], index] : undefined +} + +export function getDragOverColumn( + table: HTMLTableElement, + pointerX: number, +): [element: Element, index: number] | undefined { + const firstRow = table.querySelector('tr') + if (!firstRow) return + const cells = Array.from(firstRow.children) + return findDragOverElement(cells, pointerX, 'x') +} + +export function getDragOverRow( + table: HTMLTableElement, + pointerY: number, +): [element: Element, index: number] | undefined { + const rows = Array.from(table.querySelectorAll('tr')) + return findDragOverElement(rows, pointerY, 'y') +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts new file mode 100644 index 00000000..a29476ca --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts @@ -0,0 +1,271 @@ +import { Editor, Extension } from "@tiptap/core"; +import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state"; +import { EditorProps, EditorView } from "@tiptap/pm/view"; +import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils"; +import { getDragOverColumn, getDragOverRow } from "./calc-drag-over"; +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') + +class TableDragHandlePluginSpec implements PluginSpec { + key = TableDndKey + props: EditorProps> + + 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; + + constructor(public editor: Editor) { + this.props = { + handleDOMEvents: { + pointerover: this._pointerOver, + } + } + + 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; + wrapper.appendChild(this._colDragHandle) + wrapper.appendChild(this._rowDragHandle) + wrapper.appendChild(this._previewController.previewRoot) + wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot) + + 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()); + } + + private _pointerOver = (view: EditorView, event: PointerEvent) => { + if (this._dragging) return; + + const hoveringCell = getHoveringCell(view, event) + this._hoveringCell = hoveringCell; + if (!hoveringCell) { + this._dragHandleController.hide(); + } else { + this._dragHandleController.show(this.editor, hoveringCell); + } + } + + private _onDragColStart = (event: DragEvent) => { + this._onDragStart(event, 'col'); + } + + private _onDraggingCol = (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, 'col'); + + 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); + 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 = () => { + if (!this._dragging) return; + const direction = this._draggingDirection; + const from = this._draggingIndex; + const to = this._droppingIndex; + 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) { + 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; + } + } +} + +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] + } +}) diff --git a/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts b/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts new file mode 100644 index 00000000..33137e91 --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/handle/drag-handle-controller.ts @@ -0,0 +1,105 @@ +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`, + }); + }) + } +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts b/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts new file mode 100644 index 00000000..8848a6b0 --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/handle/empty-image-controller.ts @@ -0,0 +1,21 @@ +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(); + } +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/index.ts b/packages/editor-ext/src/lib/table/dnd/index.ts new file mode 100644 index 00000000..cb21bec1 --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/index.ts @@ -0,0 +1 @@ +export * from './dnd-extension' \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts b/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts new file mode 100644 index 00000000..0f079828 --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/preview/drop-indicator-controller.ts @@ -0,0 +1,102 @@ +import { computePosition, offset } from "@floating-ui/dom"; +import { DraggingDOMs } from "../utils"; + +const DROP_INDICATOR_WIDTH = 2; + +export class DropIndicatorController { + private _dropIndicator: HTMLElement; + + constructor() { + this._dropIndicator = document.createElement('div'); + this._dropIndicator.classList.add('table-dnd-drop-indicator'); + Object.assign(this._dropIndicator.style, { + position: 'absolute', + pointerEvents: 'none' + }); + } + + get dropIndicatorRoot() { + return this._dropIndicator; + } + + onDragStart = (relatedDoms: DraggingDOMs, type: 'col' | 'row') => { + this._initDropIndicatorStyle(relatedDoms.table, type); + this._initDropIndicatorPosition(relatedDoms.cell, type); + this._dropIndicator.dataset.dragging = 'true'; + } + + onDragEnd = () => { + Object.assign(this._dropIndicator.style, { display: 'none' }); + this._dropIndicator.dataset.dragging = 'false'; + } + + onDragging = (target: Element, direction: 'left' | 'right' | 'up' | 'down', type: 'col' | 'row') => { + if (type === 'col') { + void computePosition(target, this._dropIndicator, { + placement: direction === 'left' ? 'left' : 'right', + middleware: [offset((direction === 'left' ? -1 * DROP_INDICATOR_WIDTH : 0))], + }).then(({ x }) => { + Object.assign(this._dropIndicator.style, { left: `${x}px` }); + }) + + return; + } + + if (type === 'row') { + void computePosition(target, this._dropIndicator, { + placement: direction === 'up' ? 'top' : 'bottom', + middleware: [offset((direction === 'up' ? -1 * DROP_INDICATOR_WIDTH : 0))], + }).then(({ y }) => { + Object.assign(this._dropIndicator.style, { top: `${y}px` }); + }) + + return; + } + } + + destroy = () => { + this._dropIndicator.remove(); + } + + private _initDropIndicatorStyle = (table: HTMLElement, type: 'col' | 'row') => { + const tableRect = table.getBoundingClientRect(); + + if (type === 'col') { + Object.assign(this._dropIndicator.style, { + display: 'block', + width: `${DROP_INDICATOR_WIDTH}px`, + height: `${tableRect.height}px`, + }); + return; + } + + if (type === 'row') { + Object.assign(this._dropIndicator.style, { + display: 'block', + width: `${tableRect.width}px`, + height: `${DROP_INDICATOR_WIDTH}px`, + }); + } + } + + + private _initDropIndicatorPosition = (cell: HTMLElement, type: 'col' | 'row') => { + void computePosition(cell, this._dropIndicator, { + placement: type === 'row' ? 'right' : 'bottom', + middleware: [ + offset(({ rects }) => { + if (type === 'col') { + return -rects.reference.height + } + return -rects.reference.width + }), + ], + }).then(({ x, y }) => { + Object.assign(this._dropIndicator.style, { + left: `${x}px`, + top: `${y}px`, + }) + }); + } + +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts b/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts new file mode 100644 index 00000000..b7a0ea40 --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/preview/preview-controller.ts @@ -0,0 +1,122 @@ +import { computePosition, offset, ReferenceElement } from "@floating-ui/dom"; +import { DraggingDOMs } from "../utils"; +import { clearPreviewDOM, createPreviewDOM } from "./render-preview"; + +export class PreviewController { + private _preview: HTMLElement; + + constructor() { + this._preview = document.createElement('div'); + this._preview.classList.add('table-dnd-preview'); + this._preview.classList.add('ProseMirror'); + Object.assign(this._preview.style, { + position: 'absolute', + pointerEvents: 'none', + display: 'none', + }); + } + + get previewRoot(): HTMLElement { + return this._preview; + } + + 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); + } + + onDragEnd = () => { + clearPreviewDOM(this._preview); + Object.assign(this._preview.style, { display: 'none' }); + } + + onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => { + this._updatePreviewPosition(x, y, relatedDoms.cell, type); + } + + destroy = () => { + this._preview.remove(); + } + + private _initPreviewStyle(table: HTMLTableElement, cell: HTMLTableCellElement, type: 'col' | 'row') { + const tableRect = table.getBoundingClientRect(); + const cellRect = cell.getBoundingClientRect(); + + if (type === 'col') { + Object.assign(this._preview.style, { + display: 'block', + width: `${cellRect.width}px`, + height: `${tableRect.height}px`, + }) + } + + if (type === 'row') { + Object.assign(this._preview.style, { + display: 'block', + width: `${tableRect.width}px`, + height: `${cellRect.height}px`, + }) + } + } + + private _initPreviewPosition(cell: HTMLElement, type: 'col' | 'row') { + void computePosition(cell, this._preview, { + placement: type === 'row' ? 'right' : 'bottom', + middleware: [ + offset(({ rects }) => { + if (type === 'col') { + return -rects.reference.height + } + return -rects.reference.width + }), + ], + }).then(({ x, y }) => { + Object.assign(this._preview.style, { + left: `${x}px`, + top: `${y}px`, + }) + }); + } + + private _updatePreviewPosition(x: number, y: number, cell: HTMLElement, type: 'col' | 'row') { + computePosition( + getVirtualElement(cell, x, y), + this._preview, + { placement: type === 'row' ? 'right' : 'bottom' }, + ).then(({ x, y }) => { + if (type === 'row') { + Object.assign(this._preview.style, { + top: `${y}px`, + }) + return + } + + if (type === 'col') { + Object.assign(this._preview.style, { + left: `${x}px`, + }) + return + } + }) + } +} + +function getVirtualElement(cell: HTMLElement, x: number, y: number): ReferenceElement { + return { + contextElement: cell, + getBoundingClientRect: () => { + const rect = cell.getBoundingClientRect() + return { + width: rect.width, + height: rect.height, + right: x + rect.width / 2, + bottom: y + rect.height / 2, + top: y - rect.height / 2, + left: x - rect.width / 2, + x: x - rect.width / 2, + y: y - rect.height / 2, + } + }, + } +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/preview/render-preview.ts b/packages/editor-ext/src/lib/table/dnd/preview/render-preview.ts new file mode 100644 index 00000000..fa251f8f --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/preview/render-preview.ts @@ -0,0 +1,37 @@ +export function clearPreviewDOM(previewRoot: HTMLElement): void { + while (previewRoot.firstChild) { + previewRoot.removeChild(previewRoot.firstChild) + } +} + +export function createPreviewDOM( + table: HTMLTableElement, + previewRoot: HTMLElement, + index: number, + direction: 'row' | 'col', +): void { + clearPreviewDOM(previewRoot) + + const previewTable = document.createElement('table') + const previewTableBody = document.createElement('tbody') + previewTable.appendChild(previewTableBody) + previewRoot.appendChild(previewTable) + + const rows = table.querySelectorAll('tr') + + if (direction === 'row') { + const row = rows[index] + const rowDOM = row.cloneNode(true) + previewTableBody.appendChild(rowDOM) + } else { + rows.forEach((row) => { + const rowDOM = row.cloneNode(false) + const cells = row.querySelectorAll('th,td') + if (cells[index]) { + const cellDOM = cells[index].cloneNode(true) + rowDOM.appendChild(cellDOM) + previewTableBody.appendChild(rowDOM) + } + }) + } +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/dnd/utils.ts b/packages/editor-ext/src/lib/table/dnd/utils.ts new file mode 100644 index 00000000..d184368f --- /dev/null +++ b/packages/editor-ext/src/lib/table/dnd/utils.ts @@ -0,0 +1,107 @@ +import { cellAround, TableMap } from "@tiptap/pm/tables" +import { EditorView } from "@tiptap/pm/view" + +export function getHoveringCell( + view: EditorView, + event: MouseEvent, +): HoveringCellInfo | undefined { + 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)) + if (!$cellPos) return + + const map = TableMap.get($cellPos.node(-1)) + const tableStart = $cellPos.start(-1) + const cellRect = map.findCell($cellPos.pos - tableStart) + const rowIndex = cellRect.top + const colIndex = cellRect.left + + return { + rowIndex, + colIndex, + cellPos: $cellPos.pos, + rowFirstCellPos: getCellPos(map, tableStart, rowIndex, 0), + colFirstCellPos: getCellPos(map, tableStart, 0, colIndex), + } +} + +function domCellAround(target: HTMLElement | null): HTMLElement | null { + while (target && target.nodeName != 'TD' && target.nodeName != 'TH') { + target = target.classList?.contains('ProseMirror') + ? null + : (target.parentNode as HTMLElement | null) + } + return target +} + +export interface HoveringCellInfo { + rowIndex: number + colIndex: number + cellPos: number + rowFirstCellPos: number + colFirstCellPos: number +} + +function getCellPos( + map: TableMap, + tableStart: number, + rowIndex: number, + colIndex: number, +) { + const cellIndex = getCellIndex(map, rowIndex, colIndex) + const posInTable = map.map[cellIndex] + return tableStart + posInTable +} + +function getCellIndex( + map: TableMap, + rowIndex: number, + colIndex: number, +): number { + return map.width * rowIndex + colIndex +} + +function getTableDOMByPos(view: EditorView, pos: number): HTMLTableElement | undefined { + const dom = view.domAtPos(pos).node + if (!dom) return + const element = dom instanceof HTMLElement ? dom : dom.parentElement + const table = element?.closest('table') + return table ?? undefined +} + +function getTargetFirstCellDOM(table: HTMLTableElement, index: number, direction: 'row' | 'col'): HTMLTableCellElement | undefined { + if (direction === 'row') { + const row = table.querySelectorAll('tr')[index] + const cell = row?.querySelector('th,td') + return cell ?? undefined + } else { + const row = table.querySelector('tr') + const cell = row?.querySelectorAll('th,td')[index] + return cell ?? undefined + } +} + +export type DraggingDOMs = { + table: HTMLTableElement + cell: HTMLTableCellElement +} + +export function getDndRelatedDOMs(view: EditorView, cellPos: number | undefined, draggingIndex: number, direction: 'row' | 'col'): DraggingDOMs | undefined { + if (cellPos == null) return + const table = getTableDOMByPos(view, cellPos) + if (!table) return + const cell = getTargetFirstCellDOM(table, draggingIndex, direction) + if (!cell) return + return { table, cell } +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/index.ts b/packages/editor-ext/src/lib/table/index.ts index fd1d8019..9e5a9265 100644 --- a/packages/editor-ext/src/lib/table/index.ts +++ b/packages/editor-ext/src/lib/table/index.ts @@ -2,3 +2,4 @@ export * from "./row"; export * from "./cell"; export * from "./header"; export * from "./table"; +export * from "./dnd"; \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/utils/convert-array-of-rows-to-table-node.ts b/packages/editor-ext/src/lib/table/utils/convert-array-of-rows-to-table-node.ts new file mode 100644 index 00000000..c959ce50 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/convert-array-of-rows-to-table-node.ts @@ -0,0 +1,44 @@ +import type { Node } from '@tiptap/pm/model' +import { TableMap } from '@tiptap/pm/tables' + +/** + * Convert an array of rows to a table node. + * + * @internal + */ +export function convertArrayOfRowsToTableNode( + tableNode: Node, + arrayOfNodes: (Node | null)[][], +): Node { + const rowsPM = [] + const map = TableMap.get(tableNode) + for (let rowIndex = 0; rowIndex < map.height; rowIndex++) { + const row = tableNode.child(rowIndex) + const rowCells = [] + + for (let colIndex = 0; colIndex < map.width; colIndex++) { + if (!arrayOfNodes[rowIndex][colIndex]) continue + + const cellPos = map.map[rowIndex * map.width + colIndex] + + const cell = arrayOfNodes[rowIndex][colIndex]! + const oldCell = tableNode.nodeAt(cellPos)! + const newCell = oldCell.type.createChecked( + Object.assign({}, cell.attrs), + cell.content, + cell.marks, + ) + rowCells.push(newCell) + } + + rowsPM.push(row.type.createChecked(row.attrs, rowCells, row.marks)) + } + + const newTable = tableNode.type.createChecked( + tableNode.attrs, + rowsPM, + tableNode.marks, + ) + + return newTable +} diff --git a/packages/editor-ext/src/lib/table/utils/convert-table-node-to-array-of-rows.ts b/packages/editor-ext/src/lib/table/utils/convert-table-node-to-array-of-rows.ts new file mode 100644 index 00000000..5d85b68b --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/convert-table-node-to-array-of-rows.ts @@ -0,0 +1,61 @@ +import type { Node } from '@tiptap/pm/model' +import { TableMap } from '@tiptap/pm/tables' + +/** + * This function will transform the table node into a matrix of rows and columns + * respecting merged cells, for example this table: + * + * ``` + * ┌──────┬──────┬─────────────┐ + * │ A1 │ B1 │ C1 │ + * ├──────┼──────┴──────┬──────┤ + * │ A2 │ B2 │ │ + * ├──────┼─────────────┤ D1 │ + * │ A3 │ B3 │ C3 │ │ + * └──────┴──────┴──────┴──────┘ + * ``` + * + * will be converted to the below: + * + * ```javascript + * [ + * [A1, B1, C1, null], + * [A2, B2, null, D1], + * [A3, B3, C3, null], + * ] + * ``` + * @internal + */ +export function convertTableNodeToArrayOfRows(tableNode: Node): (Node | null)[][] { + const map = TableMap.get(tableNode) + const rows: (Node | null)[][] = [] + const rowCount = map.height + const colCount = map.width + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const row: (Node | null)[] = [] + for (let colIndex = 0; colIndex < colCount; colIndex++) { + let cellIndex = rowIndex * colCount + colIndex + let cellPos = map.map[cellIndex] + if (rowIndex > 0) { + const topCellIndex = cellIndex - colCount + const topCellPos = map.map[topCellIndex] + if (cellPos === topCellPos) { + row.push(null) + continue + } + } + if (colIndex > 0) { + const leftCellIndex = cellIndex - 1 + const leftCellPos = map.map[leftCellIndex] + if (cellPos === leftCellPos) { + row.push(null) + continue + } + } + row.push(tableNode.nodeAt(cellPos)) + } + rows.push(row) + } + + return rows +} diff --git a/packages/editor-ext/src/lib/table/utils/get-cells-in-column.ts b/packages/editor-ext/src/lib/table/utils/get-cells-in-column.ts new file mode 100644 index 00000000..0f9d6534 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/get-cells-in-column.ts @@ -0,0 +1,36 @@ +import type { Selection } from '@tiptap/pm/state' +import { TableMap } from '@tiptap/pm/tables' + +import { findTable } from './query' +import type { CellPos } from './types' + +/** + * Returns an array of cells in a column(s), where `columnIndex` could be a column index or an array of column indexes. + * + * @internal + */ +export function getCellsInColumn(columnIndexes: number | number[], selection: Selection): CellPos[] | undefined { + const table = findTable(selection.$from) + if (!table) { + return + } + + const map = TableMap.get(table.node) + const indexes = Array.isArray(columnIndexes) ? columnIndexes : [columnIndexes] + + return indexes + .filter((index) => index >= 0 && index <= map.width - 1) + .flatMap((index) => { + const cells = map.cellsInRect({ + left: index, + right: index + 1, + top: 0, + bottom: map.height, + }) + return cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos)! + const pos = nodePos + table.start + return { pos, start: pos + 1, node, depth: table.depth + 2 } + }) + }) +} diff --git a/packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts b/packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts new file mode 100644 index 00000000..75e73546 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/get-cells-in-row.ts @@ -0,0 +1,36 @@ +import type { Selection } from '@tiptap/pm/state' +import { TableMap } from '@tiptap/pm/tables' + +import { findTable } from './query' +import type { CellPos } from './types' + +/** + * Returns an array of cells in a row(s), where `rowIndex` could be a row index or an array of row indexes. + * + * @internal + */ +export function getCellsInRow(rowIndex: number | number[], selection: Selection): CellPos[] | undefined { + const table = findTable(selection.$from) + if (!table) { + return + } + + const map = TableMap.get(table.node) + const indexes = Array.isArray(rowIndex) ? rowIndex : [rowIndex] + + return indexes + .filter((index) => index >= 0 && index <= map.height - 1) + .flatMap((index) => { + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: index, + bottom: index + 1, + }) + return cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos)! + const pos = nodePos + table.start + return { pos, start: pos + 1, node, depth: table.depth + 2 } + }) + }) +} diff --git a/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.ts b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.ts new file mode 100644 index 00000000..6fda934d --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.ts @@ -0,0 +1,91 @@ +import type { Transaction } from '@tiptap/pm/state' + +import { getCellsInColumn } from './get-cells-in-column' +import { getCellsInRow } from './get-cells-in-row' +import type { CellSelectionRange } from './types' + +/** + * Returns a range of rectangular selection spanning all merged cells around a + * column at index `columnIndex`. + * + * Original implementation from Atlassian (Apache License 2.0) + * + * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-column.ts#editor/editor-tables/src/utils/get-selection-range-in-column.ts + * + * @internal + */ +export function getSelectionRangeInColumn(tr: Transaction, startColIndex: number, endColIndex: number = startColIndex): CellSelectionRange | undefined { + let startIndex = startColIndex + let endIndex = endColIndex + + // looking for selection start column (startIndex) + for (let i = startColIndex; i >= 0; i--) { + const cells = getCellsInColumn(i, tr.selection) + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.colspan + i - 1 + if (maybeEndIndex >= startIndex) { + startIndex = i + } + if (maybeEndIndex > endIndex) { + endIndex = maybeEndIndex + } + }) + } + } + // looking for selection end column (endIndex) + for (let i = startColIndex; i <= endIndex; i++) { + const cells = getCellsInColumn(i, tr.selection) + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.colspan + i - 1 + if (cell.node.attrs.colspan > 1 && maybeEndIndex > endIndex) { + endIndex = maybeEndIndex + } + }) + } + } + + // filter out columns without cells (where all rows have colspan > 1 in the same column) + const indexes = [] + for (let i = startIndex; i <= endIndex; i++) { + const maybeCells = getCellsInColumn(i, tr.selection) + if (maybeCells && maybeCells.length > 0) { + indexes.push(i) + } + } + startIndex = indexes[0] + endIndex = indexes[indexes.length - 1] + + const firstSelectedColumnCells = getCellsInColumn(startIndex, tr.selection) + const firstRowCells = getCellsInRow(0, tr.selection) + if (!firstSelectedColumnCells || !firstRowCells) { + return + } + + const $anchor = tr.doc.resolve( + firstSelectedColumnCells[firstSelectedColumnCells.length - 1].pos, + ) + + let headCell + for (let i = endIndex; i >= startIndex; i--) { + const columnCells = getCellsInColumn(i, tr.selection) + if (columnCells && columnCells.length > 0) { + for (let j = firstRowCells.length - 1; j >= 0; j--) { + if (firstRowCells[j].pos === columnCells[0].pos) { + headCell = columnCells[0] + break + } + } + if (headCell) { + break + } + } + } + if (!headCell) { + return + } + + const $head = tr.doc.resolve(headCell.pos) + return { $anchor, $head, indexes } +} diff --git a/packages/editor-ext/src/lib/table/utils/get-selection-range-in-row.ts b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-row.ts new file mode 100644 index 00000000..ece61d7f --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-row.ts @@ -0,0 +1,89 @@ +import type { Transaction } from '@tiptap/pm/state' + +import { getCellsInColumn } from './get-cells-in-column' +import { getCellsInRow } from './get-cells-in-row' +import type { CellSelectionRange } from './types' + +/** + * Returns a range of rectangular selection spanning all merged cells around a + * row at index `rowIndex`. + * + * Original implementation from Atlassian (Apache License 2.0) + * + * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-row.ts#editor/editor-tables/src/utils/get-selection-range-in-row.ts + * + * @internal + */ +export function getSelectionRangeInRow(tr: Transaction, startRowIndex: number, endRowIndex: number = startRowIndex): CellSelectionRange | undefined { + let startIndex = startRowIndex + let endIndex = endRowIndex + + // looking for selection start row (startIndex) + for (let i = startRowIndex; i >= 0; i--) { + const cells = getCellsInRow(i, tr.selection) + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.rowspan + i - 1 + if (maybeEndIndex >= startIndex) { + startIndex = i + } + if (maybeEndIndex > endIndex) { + endIndex = maybeEndIndex + } + }) + } + } + // looking for selection end row (endIndex) + for (let i = startRowIndex; i <= endIndex; i++) { + const cells = getCellsInRow(i, tr.selection) + if (cells) { + cells.forEach((cell) => { + const maybeEndIndex = cell.node.attrs.rowspan + i - 1 + if (cell.node.attrs.rowspan > 1 && maybeEndIndex > endIndex) { + endIndex = maybeEndIndex + } + }) + } + } + + // filter out rows without cells (where all columns have rowspan > 1 in the same row) + const indexes = [] + for (let i = startIndex; i <= endIndex; i++) { + const maybeCells = getCellsInRow(i, tr.selection) + if (maybeCells && maybeCells.length > 0) { + indexes.push(i) + } + } + startIndex = indexes[0] + endIndex = indexes[indexes.length - 1] + + const firstSelectedRowCells = getCellsInRow(startIndex, tr.selection) + const firstColumnCells = getCellsInColumn(0, tr.selection) + if (!firstSelectedRowCells || !firstColumnCells) { + return + } + + const $anchor = tr.doc.resolve(firstSelectedRowCells[firstSelectedRowCells.length - 1].pos) + + let headCell + for (let i = endIndex; i >= startIndex; i--) { + const rowCells = getCellsInRow(i, tr.selection) + if (rowCells && rowCells.length > 0) { + for (let j = firstColumnCells.length - 1; j >= 0; j--) { + if (firstColumnCells[j].pos === rowCells[0].pos) { + headCell = rowCells[0] + break + } + } + if (headCell) { + break + } + } + } + if (!headCell) { + return + } + + const $head = tr.doc.resolve(headCell.pos) + return { $anchor, $head, indexes } +} diff --git a/packages/editor-ext/src/lib/table/utils/index.ts b/packages/editor-ext/src/lib/table/utils/index.ts new file mode 100644 index 00000000..23169970 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/index.ts @@ -0,0 +1,3 @@ +export * from './move-column' +export * from './move-row' +export * from './query' diff --git a/packages/editor-ext/src/lib/table/utils/move-column.ts b/packages/editor-ext/src/lib/table/utils/move-column.ts new file mode 100644 index 00000000..d73e06bf --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/move-column.ts @@ -0,0 +1,81 @@ +import type { Node } from '@tiptap/pm/model' +import type { Transaction } from '@tiptap/pm/state' +import { + CellSelection, + TableMap, +} from '@tiptap/pm/tables' + +import { convertArrayOfRowsToTableNode } from './convert-array-of-rows-to-table-node' +import { convertTableNodeToArrayOfRows } from './convert-table-node-to-array-of-rows' +import { getSelectionRangeInColumn } from './get-selection-range-in-column' +import { moveRowInArrayOfRows } from './move-row-in-array-of-rows' +import { findTable } from './query' +import { transpose } from './transpose' + +export interface MoveColumnParams { + tr: Transaction + originIndex: number + targetIndex: number + select: boolean + pos: number +} + +/** + * Move a column from index `origin` to index `target`. + * + * @internal + */ +export function moveColumn(moveColParams: MoveColumnParams): boolean { + const { tr, originIndex, targetIndex, select, pos } = moveColParams + const $pos = tr.doc.resolve(pos) + const table = findTable($pos) + if (!table) return false + + const indexesOriginColumn = getSelectionRangeInColumn(tr, originIndex)?.indexes + const indexesTargetColumn = getSelectionRangeInColumn(tr, targetIndex)?.indexes + + if (!indexesOriginColumn || !indexesTargetColumn) return false + + if (indexesOriginColumn.includes(targetIndex)) return false + + const newTable = moveTableColumn( + table.node, + indexesOriginColumn, + indexesTargetColumn, + 0, + ) + + tr.replaceWith( + table.pos, + table.pos + table.node.nodeSize, + newTable, + ) + + if (!select) return true + + const map = TableMap.get(newTable) + const start = table.start + const index = targetIndex + const lastCell = map.positionAt(map.height - 1, index, newTable) + const $lastCell = tr.doc.resolve(start + lastCell) + + const firstCell = map.positionAt(0, index, newTable) + const $firstCell = tr.doc.resolve(start + firstCell) + + tr.setSelection(CellSelection.colSelection($lastCell, $firstCell)) + return true +} + +function moveTableColumn( + table: Node, + indexesOrigin: number[], + indexesTarget: number[], + direction: -1 | 1 | 0, +) { + let rows = transpose(convertTableNodeToArrayOfRows(table)) + + rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction) + rows = transpose(rows) + + return convertArrayOfRowsToTableNode(table, rows) +} diff --git a/packages/editor-ext/src/lib/table/utils/move-row-in-array-of-rows.ts b/packages/editor-ext/src/lib/table/utils/move-row-in-array-of-rows.ts new file mode 100644 index 00000000..529df254 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/move-row-in-array-of-rows.ts @@ -0,0 +1,30 @@ +/** + * Move a row in an array of rows. + * + * @internal + */ +export function moveRowInArrayOfRows( + rows: T[], + indexesOrigin: number[], + indexesTarget: number[], + directionOverride: -1 | 1 | 0, +): T[] { + const direction = indexesOrigin[0] > indexesTarget[0] ? -1 : 1 + + const rowsExtracted = rows.splice(indexesOrigin[0], indexesOrigin.length) + const positionOffset = rowsExtracted.length % 2 === 0 ? 1 : 0 + let target: number + + if (directionOverride === -1 && direction === 1) { + target = indexesTarget[0] - 1 + } else if (directionOverride === 1 && direction === -1) { + target = indexesTarget[indexesTarget.length - 1] - positionOffset + 1 + } else { + target = direction === -1 + ? indexesTarget[0] + : indexesTarget[indexesTarget.length - 1] - positionOffset + } + + rows.splice(target, 0, ...rowsExtracted) + return rows +} diff --git a/packages/editor-ext/src/lib/table/utils/move-row.ts b/packages/editor-ext/src/lib/table/utils/move-row.ts new file mode 100644 index 00000000..e69e870a --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/move-row.ts @@ -0,0 +1,74 @@ +import type { Node } from '@tiptap/pm/model' +import type { Transaction } from '@tiptap/pm/state' +import { + CellSelection, + TableMap, +} from '@tiptap/pm/tables' + +import { convertArrayOfRowsToTableNode } from './convert-array-of-rows-to-table-node' +import { convertTableNodeToArrayOfRows } from './convert-table-node-to-array-of-rows' +import { getSelectionRangeInRow } from './get-selection-range-in-row' +import { moveRowInArrayOfRows } from './move-row-in-array-of-rows' +import { findTable } from './query' + +export interface MoveRowParams { + tr: Transaction + originIndex: number + targetIndex: number + select: boolean + pos: number +} + +/** + * Move a row from index `origin` to index `target`. + * + * @internal + */ +export function moveRow(moveRowParams: MoveRowParams): boolean { + const { tr, originIndex, targetIndex, select, pos } = moveRowParams + const $pos = tr.doc.resolve(pos) + const table = findTable($pos) + if (!table) return false + + const indexesOriginRow = getSelectionRangeInRow(tr, originIndex)?.indexes + const indexesTargetRow = getSelectionRangeInRow(tr, targetIndex)?.indexes + + if (!indexesOriginRow || !indexesTargetRow) return false + + if (indexesOriginRow.includes(targetIndex)) return false + + const newTable = moveTableRow(table.node, indexesOriginRow, indexesTargetRow, 0) + + tr.replaceWith( + table.pos, + table.pos + table.node.nodeSize, + newTable, + ) + + if (!select) return true + + const map = TableMap.get(newTable) + const start = table.start + const index = targetIndex + const lastCell = map.positionAt(index, map.width - 1, newTable) + const $lastCell = tr.doc.resolve(start + lastCell) + + const firstCell = map.positionAt(index, 0, newTable) + const $firstCell = tr.doc.resolve(start + firstCell) + + tr.setSelection(CellSelection.rowSelection($lastCell, $firstCell)) + return true +} + +function moveTableRow( + table: Node, + indexesOrigin: number[], + indexesTarget: number[], + direction: -1 | 1 | 0, +) { + let rows = convertTableNodeToArrayOfRows(table) + + rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction) + + return convertArrayOfRowsToTableNode(table, rows) +} diff --git a/packages/editor-ext/src/lib/table/utils/query.ts b/packages/editor-ext/src/lib/table/utils/query.ts new file mode 100644 index 00000000..3d0a542b --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/query.ts @@ -0,0 +1,122 @@ +import type { + Node, + ResolvedPos, +} from '@tiptap/pm/model' +import type { Selection } from '@tiptap/pm/state' +import { + cellAround, + cellNear, + CellSelection, + inSameTable, +} from '@tiptap/pm/tables' + +/** + * Checks if the given object is a `CellSelection` instance. + * + * @public + */ +export function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection +} + +/** + * Find the closest table node. + * + * @internal + */ +export function findTable($pos: ResolvedPos): FindParentNodeResult | undefined { + return findParentNode((node) => node.type.spec.tableRole === 'table', $pos) +} + +/** + * Try to find the anchor and head cell in the same table by using the given + * anchor and head as hit points, or fallback to the selection's anchor and + * head. + * + * @internal + */ +export function findCellRange( + selection: Selection, + anchorHit?: number, + headHit?: number, +): [ResolvedPos, ResolvedPos] | undefined { + if (anchorHit == null && headHit == null && isCellSelection(selection)) { + return [selection.$anchorCell, selection.$headCell] + } + + const anchor: number = anchorHit ?? headHit ?? selection.anchor + const head: number = headHit ?? anchorHit ?? selection.head + + const doc = selection.$head.doc + + const $anchorCell = findCellPos(doc, anchor) + const $headCell = findCellPos(doc, head) + + if ($anchorCell && $headCell && inSameTable($anchorCell, $headCell)) { + return [$anchorCell, $headCell] + } +} + +/** + * Try to find a resolved pos of a cell by using the given pos as a hit point. + * + * @internal + */ +export function findCellPos( + doc: Node, + pos: number, +): ResolvedPos | undefined { + const $pos = doc.resolve(pos) + return cellAround($pos) || cellNear($pos) +} + +/** + * @internal + */ +export interface FindParentNodeResult { + /** + * The closest parent node that satisfies the predicate. + */ + node: Node + + /** + * The position directly before the node. + */ + pos: number + + /** + * The position at the start of the node. + */ + start: number + + /** + * The depth of the node. + */ + depth: number +} + +/** + * Find the closest parent node that satisfies the predicate. + * + * @internal + */ +export function findParentNode( + /** + * The predicate to test the parent node. + */ + predicate: (node: Node) => boolean, + /** + * The position to start searching from. + */ + $pos: ResolvedPos, +): FindParentNodeResult | undefined { + for (let depth = $pos.depth; depth >= 0; depth -= 1) { + const node = $pos.node(depth) + + if (predicate(node)) { + const pos = depth === 0 ? 0 : $pos.before(depth) + const start = $pos.start(depth) + return { node, pos, start, depth } + } + } +} diff --git a/packages/editor-ext/src/lib/table/utils/transpose.ts b/packages/editor-ext/src/lib/table/utils/transpose.ts new file mode 100644 index 00000000..dffc6e26 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/transpose.ts @@ -0,0 +1,29 @@ +/** + * Transposes a 2D array by flipping columns to rows. + * + * Transposition is a familiar algebra concept where the matrix is flipped + * along its diagonal. For more details, see: + * https://en.wikipedia.org/wiki/Transpose + * + * @example + * ```javascript + * const arr = [ + * ['a1', 'a2', 'a3'], + * ['b1', 'b2', 'b3'], + * ['c1', 'c2', 'c3'], + * ['d1', 'd2', 'd3'], + * ]; + * + * const result = transpose(arr); + * result === [ + * ['a1', 'b1', 'c1', 'd1'], + * ['a2', 'b2', 'c2', 'd2'], + * ['a3', 'b3', 'c3', 'd3'], + * ] + * ``` + */ +export function transpose(array: T[][]): T[][] { + return array[0].map((_, i) => { + return array.map((column) => column[i]) + }) +} diff --git a/packages/editor-ext/src/lib/table/utils/types.ts b/packages/editor-ext/src/lib/table/utils/types.ts new file mode 100644 index 00000000..a45f5be1 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/types.ts @@ -0,0 +1,18 @@ +import type { + Node, + ResolvedPos, +} from '@tiptap/pm/model' + +export type CellPos = { + pos: number + start: number + depth: number + node: Node +} + +export type CellSelectionRange = { + $anchor: ResolvedPos + $head: ResolvedPos + // an array of column/row indexes + indexes: number[] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9fdc892..1bb9b885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@docmost/editor-ext': specifier: workspace:* version: link:packages/editor-ext + '@floating-ui/dom': + specifier: ^1.7.3 + version: 1.7.3 '@hocuspocus/extension-redis': specifier: ^2.15.2 version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) @@ -2189,9 +2192,15 @@ packages: '@floating-ui/core@1.5.3': resolution: {integrity: sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/dom@1.6.3': resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==} + '@floating-ui/dom@1.7.3': + resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==} + '@floating-ui/react-dom@2.1.2': resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} peerDependencies: @@ -2204,6 +2213,9 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} @@ -11848,11 +11860,20 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.8 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + '@floating-ui/dom@1.6.3': dependencies: '@floating-ui/core': 1.5.3 '@floating-ui/utils': 0.2.8 + '@floating-ui/dom@1.7.3': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.6.3 @@ -11867,6 +11888,8 @@ snapshots: react-dom: 18.3.1(react@18.3.1) tabbable: 6.2.0 + '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.8': {} '@hocuspocus/common@2.15.2': From 73ee6ee8c342b78b9c9119d9a5c4f868dea9ae14 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 31 Aug 2025 18:54:52 +0100 Subject: [PATCH 03/11] feat: subpages (child pages) list node (#1462) * feat: subpages list node * disable user-select * support subpages node list in public pages --- .../public/locales/en-US/translation.json | 7 +- .../components/slash-menu/menu-items.ts | 15 ++- .../components/subpages/subpages-menu.tsx | 95 ++++++++++++++ .../components/subpages/subpages-view.tsx | 120 ++++++++++++++++++ .../components/subpages/subpages.module.css | 9 ++ .../features/editor/extensions/extensions.ts | 5 + .../src/features/editor/page-editor.tsx | 2 + .../features/editor/readonly-page-editor.tsx | 13 +- .../src/features/page/queries/page-query.ts | 1 + .../src/features/page/types/page.types.ts | 2 +- .../features/share/atoms/shared-page-atom.ts | 6 + .../features/share/components/share-shell.tsx | 20 ++- .../share/hooks/use-shared-page-subpages.ts | 29 +++++ apps/client/src/pages/share/shared-page.tsx | 1 + .../src/collaboration/collaboration.util.ts | 2 + .../src/core/page/dto/sidebar-page.dto.ts | 8 +- apps/server/src/core/page/page.controller.ts | 29 +++-- packages/editor-ext/src/index.ts | 1 + packages/editor-ext/src/lib/subpages/index.ts | 2 + .../editor-ext/src/lib/subpages/subpages.ts | 68 ++++++++++ 20 files changed, 410 insertions(+), 25 deletions(-) create mode 100644 apps/client/src/features/editor/components/subpages/subpages-menu.tsx create mode 100644 apps/client/src/features/editor/components/subpages/subpages-view.tsx create mode 100644 apps/client/src/features/editor/components/subpages/subpages.module.css create mode 100644 apps/client/src/features/share/atoms/shared-page-atom.ts create mode 100644 apps/client/src/features/share/hooks/use-shared-page-subpages.ts create mode 100644 packages/editor-ext/src/lib/subpages/index.ts create mode 100644 packages/editor-ext/src/lib/subpages/subpages.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index efad41cc..3efcdfec 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -495,5 +495,10 @@ "Page restored successfully": "Page restored successfully", "Deleted by": "Deleted by", "Deleted at": "Deleted at", - "Preview": "Preview" + "Preview": "Preview", + "Subpages": "Subpages", + "Failed to load subpages": "Failed to load subpages", + "No subpages": "No subpages", + "Subpages (Child pages)": "Subpages (Child pages)", + "List all subpages of the current page": "List all subpages of the current page" } diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 42bed5c1..f56d7f04 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -17,8 +17,10 @@ import { IconTable, IconTypography, IconMenu4, - IconCalendar, IconAppWindow, -} from '@tabler/icons-react'; + IconCalendar, + IconAppWindow, + IconSitemap, +} from "@tabler/icons-react"; import { CommandProps, SlashMenuGroupedItemsType, @@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); }, }, + { + title: "Subpages (Child pages)", + description: "List all subpages of the current page", + searchTerms: ["subpages", "child", "children", "nested", "hierarchy"], + icon: IconSitemap, + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).insertSubpages().run(); + }, + }, { title: "Iframe embed", description: "Embed any Iframe", diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx new file mode 100644 index 00000000..6cc017e2 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -0,0 +1,95 @@ +import { + BubbleMenu as BaseBubbleMenu, + posToDOMRect, + findParentNode, +} from "@tiptap/react"; +import { Node as PMNode } from "@tiptap/pm/model"; +import React, { useCallback } from "react"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { IconTrash } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Editor } from "@tiptap/core"; +import { sticky } from "tippy.js"; + +interface SubpagesMenuProps { + editor: Editor; +} + +interface ShouldShowProps { + state: any; + from?: number; + to?: number; +} + +export const SubpagesMenu = React.memo( + ({ editor }: SubpagesMenuProps): JSX.Element => { + const { t } = useTranslation(); + + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("subpages"); + }, + [editor], + ); + + const getReferenceClientRect = useCallback(() => { + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "subpages"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + return dom.getBoundingClientRect(); + } + + return posToDOMRect(editor.view, selection.from, selection.to); + }, [editor]); + + const deleteNode = useCallback(() => { + const { selection } = editor.state; + editor + .chain() + .focus() + .setNodeSelection(selection.from) + .deleteSelection() + .run(); + }, [editor]); + + return ( + + + + + + + + ); + }, +); + +export default SubpagesMenu; diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx new file mode 100644 index 00000000..525e2ec9 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx @@ -0,0 +1,120 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { Stack, Text, Anchor, ActionIcon } from "@mantine/core"; +import { IconFileDescription } from "@tabler/icons-react"; +import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query"; +import { useMemo } from "react"; +import { Link, useParams } from "react-router-dom"; +import classes from "./subpages.module.css"; +import styles from "../mention/mention.module.css"; +import { + buildPageUrl, + buildSharedPageUrl, +} from "@/features/page/page.utils.ts"; +import { useTranslation } from "react-i18next"; +import { sortPositionKeys } from "@/features/page/tree/utils/utils"; +import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages"; + +export default function SubpagesView(props: NodeViewProps) { + const { editor } = props; + const { spaceSlug, shareId } = useParams(); + const { t } = useTranslation(); + + const currentPageId = editor.storage.pageId; + + // Get subpages from shared tree if we're in a shared context + const sharedSubpages = useSharedPageSubpages(currentPageId); + + const { data, isLoading, error } = useGetSidebarPagesQuery({ + pageId: currentPageId, + }); + + const subpages = useMemo(() => { + // If we're in a shared context, use the shared subpages + if (shareId && sharedSubpages) { + return sharedSubpages.map((node) => ({ + id: node.value, + slugId: node.slugId, + title: node.name, + icon: node.icon, + position: node.position, + })); + } + + // Otherwise use the API data + if (!data?.pages) return []; + const allPages = data.pages.flatMap((page) => page.items); + return sortPositionKeys(allPages); + }, [data, shareId, sharedSubpages]); + + if (isLoading && !shareId) { + return null; + } + + if (error && !shareId) { + return ( + + + {t("Failed to load subpages")} + + + ); + } + + if (subpages.length === 0) { + return ( + +
+ + {t("No subpages")} + +
+
+ ); + } + + return ( + +
+ + {subpages.map((page) => ( + + {page?.icon ? ( + {page.icon} + ) : ( + + + + )} + + + {page?.title || t("untitled")} + + + ))} + +
+
+ ); +} diff --git a/apps/client/src/features/editor/components/subpages/subpages.module.css b/apps/client/src/features/editor/components/subpages/subpages.module.css new file mode 100644 index 00000000..fb43eaf6 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages.module.css @@ -0,0 +1,9 @@ +.container { + margin: 0; + padding-left: 4px; + user-select: none; + + a { + border: none !important; + } +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index adfed7ed..51009b43 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -38,6 +38,7 @@ import { Embed, SearchAndReplace, Mention, + Subpages, TableDndExtension, } from "@docmost/editor-ext"; import { @@ -58,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi import DrawioView from "../components/drawio/drawio-view"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; +import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import plaintext from "highlight.js/lib/languages/plaintext"; import powershell from "highlight.js/lib/languages/powershell"; import abap from "highlightjs-sap-abap"; @@ -214,6 +216,9 @@ export const mainExtensions = [ Embed.configure({ view: EmbedView, }), + Subpages.configure({ + view: SubpagesView, + }), MarkdownClipboard.configure({ transformPastedText: true, }), diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 44aa403b..f68f50de 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -31,6 +31,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx"; 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"; +import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx"; import { handleFileDrop, handlePaste, @@ -391,6 +392,7 @@ export default function PageEditor({ + diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index bd6b9f6b..c1352354 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document"; import { Heading } from "@tiptap/extension-heading"; import { Text } from "@tiptap/extension-text"; import { Placeholder } from "@tiptap/extension-placeholder"; -import { useAtom } from "jotai/index"; -import { - pageEditorAtom, - readOnlyEditorAtom, -} from "@/features/editor/atoms/editor-atoms.ts"; -import { Editor } from "@tiptap/core"; +import { useAtom } from "jotai"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; interface PageEditorProps { title: string; content: any; + pageId?: string; } export default function ReadonlyPageEditor({ title, content, + pageId, }: PageEditorProps) { const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); @@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({ content={content} onCreate={({ editor }) => { if (editor) { + if (pageId) { + editor.storage.pageId = pageId; + } // @ts-ignore setReadOnlyEditor(editor); } diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index cfd8b10a..64d03ddd 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -252,6 +252,7 @@ export function useGetSidebarPagesQuery( ): UseInfiniteQueryResult, unknown>> { return useInfiniteQuery({ queryKey: ["sidebar-pages", data], + enabled: !!data?.pageId || !!data?.spaceId, queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }), initialPageParam: 1, getPreviousPageParam: (firstPage) => diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 052dda35..a5078564 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -60,7 +60,7 @@ export interface ICopyPageToSpace { } export interface SidebarPagesParams { - spaceId: string; + spaceId?: string; pageId?: string; page?: number; // pagination } diff --git a/apps/client/src/features/share/atoms/shared-page-atom.ts b/apps/client/src/features/share/atoms/shared-page-atom.ts new file mode 100644 index 00000000..813f5e61 --- /dev/null +++ b/apps/client/src/features/share/atoms/shared-page-atom.ts @@ -0,0 +1,6 @@ +import { atom } from "jotai"; +import { ISharedPageTree } from "@/features/share/types/share.types"; +import { SharedPageTreeNode } from "@/features/share/utils"; + +export const sharedPageTreeAtom = atom(null); +export const sharedTreeDataAtom = atom(null); \ No newline at end of file diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx index 8550f59f..af207f4c 100644 --- a/apps/client/src/features/share/components/share-shell.tsx +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useMemo } from "react"; import { ActionIcon, Affix, @@ -14,8 +14,10 @@ import SharedTree from "@/features/share/components/shared-tree.tsx"; import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { ThemeToggle } from "@/components/theme-toggle.tsx"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useAtom } from "jotai"; +import { sharedPageTreeAtom, sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom"; +import { buildSharedPageTree } from "@/features/share/utils"; import { desktopSidebarAtom, mobileSidebarAtom, @@ -59,6 +61,20 @@ export default function ShareShell({ const { shareId } = useParams(); const { data } = useGetSharedPageTreeQuery(shareId); const readOnlyEditor = useAtomValue(readOnlyEditorAtom); + + const setSharedPageTree = useSetAtom(sharedPageTreeAtom); + const setSharedTreeData = useSetAtom(sharedTreeDataAtom); + + // Build and set the tree data when it changes + const treeData = useMemo(() => { + if (!data?.pageTree) return null; + return buildSharedPageTree(data.pageTree); + }, [data?.pageTree]); + + useEffect(() => { + setSharedPageTree(data || null); + setSharedTreeData(treeData); + }, [data, treeData, setSharedPageTree, setSharedTreeData]); return ( { + if (!treeData || !pageId) return []; + + function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] { + for (const node of nodes) { + if (node.value === pageId || node.slugId === pageId) { + return node.children || []; + } + if (node.children && node.children.length > 0) { + const subpages = findSubpages(node.children); + if (subpages.length > 0) { + return subpages; + } + } + } + return []; + } + + return findSubpages(treeData); + }, [treeData, pageId]); +} diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index 50e5837d..274b76cb 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -52,6 +52,7 @@ export default function SharedPage() { key={data.page.id} title={data.page.title} content={data.page.content} + pageId={data.page.id} /> diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 099d615e..37645f44 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -32,6 +32,7 @@ import { Excalidraw, Embed, Mention, + Subpages, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML } from '../common/helpers/prosemirror/html'; @@ -79,6 +80,7 @@ export const tiptapExtensions = [ Excalidraw, Embed, Mention, + Subpages, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/core/page/dto/sidebar-page.dto.ts b/apps/server/src/core/page/dto/sidebar-page.dto.ts index 4ea2bb20..012f64b0 100644 --- a/apps/server/src/core/page/dto/sidebar-page.dto.ts +++ b/apps/server/src/core/page/dto/sidebar-page.dto.ts @@ -1,7 +1,11 @@ -import { IsOptional, IsString } from 'class-validator'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; import { SpaceIdDto } from './page.dto'; -export class SidebarPageDto extends SpaceIdDto { +export class SidebarPageDto { + @IsOptional() + @IsUUID() + spaceId: string; + @IsOptional() @IsString() pageId: string; diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 2f6dcf60..450874f7 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -254,21 +254,28 @@ export class PageController { @Body() pagination: PaginationOptions, @AuthUser() user: User, ) { - const ability = await this.spaceAbility.createForUser(user, dto.spaceId); + if (!dto.spaceId && !dto.pageId) { + throw new BadRequestException( + 'Either spaceId or pageId must be provided', + ); + } + let spaceId = dto.spaceId; + + if (dto.pageId) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new ForbiddenException(); + } + + spaceId = page.spaceId; + } + + const ability = await this.spaceAbility.createForUser(user, spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } - let pageId = null; - if (dto.pageId) { - const page = await this.pageRepo.findById(dto.pageId); - if (page.spaceId !== dto.spaceId) { - throw new ForbiddenException(); - } - pageId = page.id; - } - - return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId); + return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId); } @HttpCode(HttpStatus.OK) diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index d3e1d53d..a0efaa1b 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -19,3 +19,4 @@ export * from "./lib/mention"; export * from "./lib/markdown"; export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; +export * from "./lib/subpages"; diff --git a/packages/editor-ext/src/lib/subpages/index.ts b/packages/editor-ext/src/lib/subpages/index.ts new file mode 100644 index 00000000..27a974a6 --- /dev/null +++ b/packages/editor-ext/src/lib/subpages/index.ts @@ -0,0 +1,2 @@ +export { Subpages } from "./subpages"; +export type { SubpagesAttributes, SubpagesOptions } from "./subpages"; diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts new file mode 100644 index 00000000..620f0342 --- /dev/null +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -0,0 +1,68 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface SubpagesOptions { + HTMLAttributes: Record; + view: any; +} + +export interface SubpagesAttributes {} + +declare module "@tiptap/core" { + interface Commands { + subpages: { + insertSubpages: (attributes?: SubpagesAttributes) => ReturnType; + }; + } +} + +export const Subpages = Node.create({ + name: "subpages", + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + group: "block", + atom: true, + draggable: false, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ]; + }, + + addCommands() { + return { + insertSubpages: + (attributes) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: attributes, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(this.options.view); + }, +}); From 60a373f4889cb561196d144356c1442da0fe6686 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 31 Aug 2025 12:04:27 -0700 Subject: [PATCH 04/11] fix: readonly editor table responsiveness --- packages/editor-ext/src/lib/table/table.ts | 30 +++++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/editor-ext/src/lib/table/table.ts b/packages/editor-ext/src/lib/table/table.ts index 549847b8..87053832 100644 --- a/packages/editor-ext/src/lib/table/table.ts +++ b/packages/editor-ext/src/lib/table/table.ts @@ -1,29 +1,34 @@ import Table from "@tiptap/extension-table"; import { Editor } from "@tiptap/core"; +import { DOMOutputSpec } from "@tiptap/pm/model"; const LIST_TYPES = ["bulletList", "orderedList", "taskList"]; function isInList(editor: Editor): boolean { const { $from } = editor.state.selection; - + for (let depth = $from.depth; depth > 0; depth--) { const node = $from.node(depth); if (LIST_TYPES.includes(node.type.name)) { return true; } } - + return false; } function handleListIndent(editor: Editor): boolean { - return editor.commands.sinkListItem("listItem") || - editor.commands.sinkListItem("taskItem"); + return ( + editor.commands.sinkListItem("listItem") || + editor.commands.sinkListItem("taskItem") + ); } function handleListOutdent(editor: Editor): boolean { - return editor.commands.liftListItem("listItem") || - editor.commands.liftListItem("taskItem"); + return ( + editor.commands.liftListItem("listItem") || + editor.commands.liftListItem("taskItem") + ); } export const CustomTable = Table.extend({ @@ -62,4 +67,15 @@ export const CustomTable = Table.extend({ }, }; }, -}); \ No newline at end of file + + renderHTML({ node, HTMLAttributes }) { + // https://github.com/ueberdosis/tiptap/issues/4872#issuecomment-2717554498 + const originalRender = this.parent?.({ node, HTMLAttributes }); + const wrapper: DOMOutputSpec = [ + "div", + { class: "tableWrapper" }, + originalRender, + ]; + return wrapper; + }, +}); From 937386e42bf879bc125c39fd34c83f147c604500 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 31 Aug 2025 12:08:02 -0700 Subject: [PATCH 05/11] fix: hide table handles in readonly mode --- packages/editor-ext/src/lib/table/dnd/dnd-extension.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts index a29476ca..b4ac2950 100644 --- a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts +++ b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts @@ -81,6 +81,12 @@ class TableDragHandlePluginSpec implements PluginSpec { private _pointerOver = (view: EditorView, event: PointerEvent) => { if (this._dragging) return; + + // Don't show drag handles in readonly mode + if (!this.editor.isEditable) { + this._dragHandleController.hide(); + return; + } const hoveringCell = getHoveringCell(view, event) this._hoveringCell = hoveringCell; From 509622af54acd0b791d7935d4c5ce90300f5f9e7 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 31 Aug 2025 12:20:40 -0700 Subject: [PATCH 06/11] ignore type error --- .../features/share/components/share-shell.tsx | 10 +++++----- ...1T191600-add-group-sync-to-auth-providers.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/database/migrations/20250831T191600-add-group-sync-to-auth-providers.ts diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx index af207f4c..10b2a74d 100644 --- a/apps/client/src/features/share/components/share-shell.tsx +++ b/apps/client/src/features/share/components/share-shell.tsx @@ -1,9 +1,7 @@ import React, { useEffect, useMemo } from "react"; import { ActionIcon, - Affix, AppShell, - Button, Group, ScrollArea, Tooltip, @@ -61,16 +59,18 @@ export default function ShareShell({ const { shareId } = useParams(); const { data } = useGetSharedPageTreeQuery(shareId); const readOnlyEditor = useAtomValue(readOnlyEditorAtom); - + + // @ts-ignore const setSharedPageTree = useSetAtom(sharedPageTreeAtom); + // @ts-ignore const setSharedTreeData = useSetAtom(sharedTreeDataAtom); - + // Build and set the tree data when it changes const treeData = useMemo(() => { if (!data?.pageTree) return null; return buildSharedPageTree(data.pageTree); }, [data?.pageTree]); - + useEffect(() => { setSharedPageTree(data || null); setSharedTreeData(treeData); diff --git a/apps/server/src/database/migrations/20250831T191600-add-group-sync-to-auth-providers.ts b/apps/server/src/database/migrations/20250831T191600-add-group-sync-to-auth-providers.ts new file mode 100644 index 00000000..2f06989f --- /dev/null +++ b/apps/server/src/database/migrations/20250831T191600-add-group-sync-to-auth-providers.ts @@ -0,0 +1,17 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('auth_providers') + .addColumn('is_group_sync_enabled', 'boolean', (col) => + col.defaultTo(false).notNull(), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('auth_providers') + .dropColumn('is_group_sync_enabled') + .execute(); +} \ No newline at end of file From 74cd890bddd7ef296572b5f7598727eedbc09984 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:33:37 +0100 Subject: [PATCH 07/11] feat(EE): implement SSO group sync for SAML and OIDC (#1452) * feat: implement SSO group synchronization for SAML and OIDC - Add group_sync column to auth_providers table - Extract groups from SAML attributes (memberOf, groups, roles) - Extract groups from OIDC claims (groups, roles) - Implement case-insensitive group matching with auto-creation - Sync user groups on each SSO login - Ensure only one provider can have group sync enabled at a time - Add group sync toggle to SAML and OIDC configuration forms * rename column --- .../src/ee/security/components/sso-oidc-form.tsx | 14 ++++++++++++++ .../src/ee/security/components/sso-saml-form.tsx | 14 ++++++++++++++ .../client/src/ee/security/types/security.types.ts | 1 + ...0831T191600-add-group-sync-to-auth-providers.ts | 8 +++----- apps/server/src/database/types/db.d.ts | 1 + apps/server/src/ee | 2 +- 6 files changed, 34 insertions(+), 6 deletions(-) diff --git a/apps/client/src/ee/security/components/sso-oidc-form.tsx b/apps/client/src/ee/security/components/sso-oidc-form.tsx index 28b1ac47..aa9e653c 100644 --- a/apps/client/src/ee/security/components/sso-oidc-form.tsx +++ b/apps/client/src/ee/security/components/sso-oidc-form.tsx @@ -16,6 +16,7 @@ const ssoSchema = z.object({ oidcClientSecret: z.string().min(1, "Client secret is required"), isEnabled: z.boolean(), allowSignup: z.boolean(), + groupSync: z.boolean(), }); type SSOFormValues = z.infer; @@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) { oidcClientSecret: provider.oidcClientSecret || "", isEnabled: provider.isEnabled, allowSignup: provider.allowSignup, + groupSync: provider.groupSync || false, }, validate: zodResolver(ssoSchema), }); @@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) { if (form.isDirty("allowSignup")) { ssoData.allowSignup = values.allowSignup; } + if (form.isDirty("groupSync")) { + ssoData.groupSync = values.groupSync; + } await updateSsoProviderMutation.mutateAsync(ssoData); form.resetDirty(); @@ -119,6 +124,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) { /> + +
{t("Group sync")}
+ +
+
{t("Enabled")}
; @@ -45,6 +46,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) { samlCertificate: provider.samlCertificate || "", isEnabled: provider.isEnabled, allowSignup: provider.allowSignup, + groupSync: provider.groupSync || false, }, validate: zodResolver(ssoSchema), }); @@ -75,6 +77,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) { if (form.isDirty("allowSignup")) { ssoData.allowSignup = values.allowSignup; } + if (form.isDirty("groupSync")) { + ssoData.groupSync = values.groupSync; + } await updateSsoProviderMutation.mutateAsync(ssoData); form.resetDirty(); @@ -132,6 +137,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) { />
+ +
{t("Group sync")}
+ +
+
{t("Enabled")}
): Promise { await db.schema .alterTable('auth_providers') - .addColumn('is_group_sync_enabled', 'boolean', (col) => - col.defaultTo(false).notNull(), - ) + .addColumn('group_sync', 'boolean', (col) => col.defaultTo(false).notNull()) .execute(); } export async function down(db: Kysely): Promise { await db.schema .alterTable('auth_providers') - .dropColumn('is_group_sync_enabled') + .dropColumn('group_sync') .execute(); -} \ No newline at end of file +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index e8662649..ab0fa898 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -62,6 +62,7 @@ export interface AuthProviders { deletedAt: Timestamp | null; id: Generated; isEnabled: Generated; + groupSync: Generated; name: string; oidcClientId: string | null; oidcClientSecret: string | null; diff --git a/apps/server/src/ee b/apps/server/src/ee index fbc01d80..10519eea 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit fbc01d808f3edd7d16a64c21251a0bcb720f1ba4 +Subproject commit 10519eeaa24881201f21f79fc5edc632fe2c185c From 242fb6bb572fa0d400f254687caa145f44b44b70 Mon Sep 17 00:00:00 2001 From: Alexander Schaber Date: Sun, 31 Aug 2025 21:48:59 +0200 Subject: [PATCH 08/11] fix: set mermaid theme based on computed color scheme (#1438) --- .../components/code-block/mermaid-view.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/client/src/features/editor/components/code-block/mermaid-view.tsx b/apps/client/src/features/editor/components/code-block/mermaid-view.tsx index 5f2cf845..e871e2f4 100644 --- a/apps/client/src/features/editor/components/code-block/mermaid-view.tsx +++ b/apps/client/src/features/editor/components/code-block/mermaid-view.tsx @@ -4,11 +4,7 @@ import mermaid from "mermaid"; import { v4 as uuidv4 } from "uuid"; import classes from "./code-block.module.css"; import { useTranslation } from "react-i18next"; - -mermaid.initialize({ - startOnLoad: false, - suppressErrorRendering: true, -}); +import { useComputedColorScheme } from "@mantine/core"; interface MermaidViewProps { props: NodeViewProps; @@ -16,12 +12,22 @@ interface MermaidViewProps { export default function MermaidView({ props }: MermaidViewProps) { const { t } = useTranslation(); + const computedColorScheme = useComputedColorScheme(); const { node } = props; const [preview, setPreview] = useState(""); + // Update Mermaid config when theme changes. + useEffect(() => { + mermaid.initialize({ + startOnLoad: false, + suppressErrorRendering: true, + theme: computedColorScheme === "light" ? "default" : "dark", + }); + }, [computedColorScheme]); + + // Re-render the diagram whenever the node content or theme changes. useEffect(() => { const id = `mermaid-${uuidv4()}`; - if (node.textContent.length > 0) { mermaid .render(id, node.textContent) @@ -40,7 +46,7 @@ export default function MermaidView({ props }: MermaidViewProps) { } }); } - }, [node.textContent]); + }, [node.textContent, computedColorScheme]); return (
Date: Sun, 31 Aug 2025 22:16:52 +0200 Subject: [PATCH 09/11] feat: emoji callout icon (#1323) --- .../client/src/components/ui/emoji-picker.tsx | 13 ++++- .../components/callout/callout-menu.tsx | 49 ++++++++++++++++++- .../components/callout/callout-view.tsx | 10 ++-- .../editor-ext/src/lib/callout/callout.ts | 19 +++++++ 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/apps/client/src/components/ui/emoji-picker.tsx b/apps/client/src/components/ui/emoji-picker.tsx index 952c1171..112c2d9c 100644 --- a/apps/client/src/components/ui/emoji-picker.tsx +++ b/apps/client/src/components/ui/emoji-picker.tsx @@ -15,6 +15,11 @@ export interface EmojiPickerInterface { icon: ReactNode; removeEmojiAction: () => void; readOnly: boolean; + actionIconProps?: { + size?: string; + variant?: string; + c?: string; + }; } function EmojiPicker({ @@ -22,6 +27,7 @@ function EmojiPicker({ icon, removeEmojiAction, readOnly, + actionIconProps, }: EmojiPickerInterface) { const { t } = useTranslation(); const [opened, handlers] = useDisclosure(false); @@ -64,7 +70,12 @@ function EmojiPicker({ closeOnEscape={true} > - + {icon} diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index 56dea233..400460e7 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -9,18 +9,21 @@ import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; -import { ActionIcon, Tooltip } from "@mantine/core"; +import { ActionIcon, Tooltip, Divider } from "@mantine/core"; import { IconAlertTriangleFilled, IconCircleCheckFilled, IconCircleXFilled, IconInfoCircleFilled, + IconMoodSmile, } from "@tabler/icons-react"; import { CalloutType } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; +import EmojiPicker from "@/components/ui/emoji-picker.tsx"; export function CalloutMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); + const shouldShow = useCallback( ({ state }: ShouldShowProps) => { if (!state) { @@ -56,6 +59,36 @@ export function CalloutMenu({ editor }: EditorMenuProps) { [editor], ); + const setCalloutIcon = useCallback( + (emoji: any) => { + const emojiChar = emoji?.native || emoji?.emoji || emoji; + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .updateCalloutIcon(emojiChar) + .run(); + }, + [editor], + ); + + const removeCalloutIcon = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .updateCalloutIcon("") + .run(); + }, [editor]); + + const getCurrentIcon = () => { + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "callout"; + const parent = findParentNode(predicate)(selection); + const icon = parent?.node.attrs.icon; + return icon || null; + }; + + const currentIcon = getCurrentIcon(); + return ( + + + } + actionIconProps={{ + size: "lg", + variant: "default", + c: undefined + }} + /> + ); diff --git a/apps/client/src/features/editor/components/callout/callout-view.tsx b/apps/client/src/features/editor/components/callout/callout-view.tsx index 0df410a3..5583bd87 100644 --- a/apps/client/src/features/editor/components/callout/callout-view.tsx +++ b/apps/client/src/features/editor/components/callout/callout-view.tsx @@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext"; export default function CalloutView(props: NodeViewProps) { const { node } = props; - const { type } = node.attrs; + const { type, icon } = node.attrs; return ( @@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) { variant="light" title="" color={getCalloutColor(type)} - icon={getCalloutIcon(type)} + icon={getCalloutIcon(type, icon)} p="xs" classNames={{ message: classes.message, @@ -32,7 +32,11 @@ export default function CalloutView(props: NodeViewProps) { ); } -function getCalloutIcon(type: CalloutType) { +function getCalloutIcon(type: CalloutType, customIcon?: string) { + if (customIcon && customIcon.trim() !== "") { + return {customIcon}; + } + switch (type) { case "info": return ; diff --git a/packages/editor-ext/src/lib/callout/callout.ts b/packages/editor-ext/src/lib/callout/callout.ts index c756917b..97c5dfcc 100644 --- a/packages/editor-ext/src/lib/callout/callout.ts +++ b/packages/editor-ext/src/lib/callout/callout.ts @@ -18,6 +18,10 @@ export interface CalloutAttributes { * The type of callout. */ type: CalloutType; + /** + * The custom icon name for the callout. + */ + icon?: string; } declare module "@tiptap/core" { @@ -27,6 +31,7 @@ declare module "@tiptap/core" { unsetCallout: () => ReturnType; toggleCallout: (attributes?: CalloutAttributes) => ReturnType; updateCalloutType: (type: CalloutType) => ReturnType; + updateCalloutIcon: (icon: string) => ReturnType; }; } } @@ -58,6 +63,13 @@ export const Callout = Node.create({ "data-callout-type": attributes.type, }), }, + icon: { + default: null, + parseHTML: (element) => element.getAttribute("data-callout-icon"), + renderHTML: (attributes) => ({ + "data-callout-icon": attributes.icon, + }), + }, }; }, @@ -107,6 +119,13 @@ export const Callout = Node.create({ commands.updateAttributes("callout", { type: getValidCalloutType(type), }), + + updateCalloutIcon: + (icon: string) => + ({ commands }) => + commands.updateAttributes("callout", { + icon: icon || null, + }), }; }, From dcbb65d799afa956ac628d7ceb0a287bdd427f8b Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 2 Sep 2025 04:59:01 +0100 Subject: [PATCH 10/11] feat(EE): LDAP integration (#1515) * LDAP - WIP * WIP * add hasGeneratedPassword * fix jotai atom * - don't require password confirmation for MFA is user has auto generated password (LDAP) - cleanups * fix * reorder * update migration * update default * fix type error --- .../src/ee/components/ldap-login-modal.tsx | 124 ++++++++++ apps/client/src/ee/components/sso-login.tsx | 53 +++- .../mfa/components/mfa-backup-code-input.tsx | 1 + .../mfa/components/mfa-backup-codes-modal.tsx | 41 +++- .../src/ee/mfa/components/mfa-challenge.tsx | 1 + .../ee/mfa/components/mfa-disable-modal.tsx | 50 ++-- .../src/ee/mfa/components/mfa-setup-modal.tsx | 1 + .../client/src/ee/mfa/services/mfa-service.ts | 2 +- apps/client/src/ee/mfa/types/mfa.types.ts | 2 +- .../security/components/allowed-domains.tsx | 3 +- .../components/create-sso-provider.tsx | 22 +- .../ee/security/components/sso-ldap-form.tsx | 228 ++++++++++++++++++ .../ee/security/components/sso-oidc-form.tsx | 18 +- .../components/sso-provider-modal.tsx | 5 + .../ee/security/components/sso-saml-form.tsx | 18 +- apps/client/src/ee/security/contants.ts | 1 + .../ee/security/services/ldap-auth-service.ts | 23 ++ .../src/ee/security/types/security.types.ts | 8 + .../features/user/atoms/current-user-atom.ts | 39 ++- .../user/components/account-language.tsx | 2 +- .../src/features/user/types/user.types.ts | 1 + .../components/workspace-name-form.tsx | 3 +- apps/server/package.json | 1 + .../src/core/auth/services/auth.service.ts | 2 + .../migrations/20250831T202306-ldap-auth.ts | 68 ++++++ .../src/database/repos/user/user.repo.ts | 1 + apps/server/src/database/types/db.d.ts | 15 +- apps/server/src/ee | 2 +- pnpm-lock.yaml | 78 +++++- 29 files changed, 723 insertions(+), 90 deletions(-) create mode 100644 apps/client/src/ee/components/ldap-login-modal.tsx create mode 100644 apps/client/src/ee/security/components/sso-ldap-form.tsx create mode 100644 apps/client/src/ee/security/services/ldap-auth-service.ts create mode 100644 apps/server/src/database/migrations/20250831T202306-ldap-auth.ts diff --git a/apps/client/src/ee/components/ldap-login-modal.tsx b/apps/client/src/ee/components/ldap-login-modal.tsx new file mode 100644 index 00000000..9360651d --- /dev/null +++ b/apps/client/src/ee/components/ldap-login-modal.tsx @@ -0,0 +1,124 @@ +import React, { useState } from "react"; +import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { z } from "zod"; +import { notifications } from "@mantine/notifications"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IAuthProvider } from "@/ee/security/types/security.types"; +import APP_ROUTE from "@/lib/app-route"; +import { ldapLogin } from "@/ee/security/services/ldap-auth-service"; + +const formSchema = z.object({ + username: z.string().min(1, { message: "Username is required" }), + password: z.string().min(1, { message: "Password is required" }), +}); + +interface LdapLoginModalProps { + opened: boolean; + onClose: () => void; + provider: IAuthProvider; + workspaceId: string; +} + +export function LdapLoginModal({ + opened, + onClose, + provider, + workspaceId, +}: LdapLoginModalProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + username: "", + password: "", + }, + }); + + const handleSubmit = async (values: { + username: string; + password: string; + }) => { + setIsLoading(true); + setError(null); + + try { + const response = await ldapLogin({ + username: values.username, + password: values.password, + providerId: provider.id, + workspaceId, + }); + + // Handle MFA like the regular login + if (response?.userHasMfa) { + onClose(); + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + } else if (response?.requiresMfaSetup) { + onClose(); + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + } else { + onClose(); + navigate(APP_ROUTE.HOME); + } + } catch (err: any) { + setIsLoading(false); + const errorMessage = + err.response?.data?.message || "Authentication failed"; + setError(errorMessage); + + notifications.show({ + message: errorMessage, + color: "red", + }); + } + }; + + const handleClose = () => { + form.reset(); + setError(null); + onClose(); + }; + + return ( + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/components/sso-login.tsx b/apps/client/src/ee/components/sso-login.tsx index 8de93c29..8c96d9c5 100644 --- a/apps/client/src/ee/components/sso-login.tsx +++ b/apps/client/src/ee/components/sso-login.tsx @@ -1,29 +1,62 @@ +import { useState } from "react"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { Button, Divider, Stack } from "@mantine/core"; -import { IconLock } from "@tabler/icons-react"; +import { IconLock, IconServer } from "@tabler/icons-react"; import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { isCloud } from "@/lib/config.ts"; +import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx"; export default function SsoLogin() { const { data, isLoading } = useWorkspacePublicDataQuery(); + const [ldapModalOpened, setLdapModalOpened] = useState(false); + const [selectedLdapProvider, setSelectedLdapProvider] = useState(null); if (!data?.authProviders || data?.authProviders?.length === 0) { return null; } const handleSsoLogin = (provider: IAuthProvider) => { - window.location.href = buildSsoLoginUrl({ - providerId: provider.id, - type: provider.type, - workspaceId: data.id, - }); + if (provider.type === SSO_PROVIDER.LDAP) { + // Open modal for LDAP instead of redirecting + setSelectedLdapProvider(provider); + setLdapModalOpened(true); + } else { + // Redirect for other SSO providers + window.location.href = buildSsoLoginUrl({ + providerId: provider.id, + type: provider.type, + workspaceId: data.id, + }); + } + }; + + const getProviderIcon = (provider: IAuthProvider) => { + if (provider.type === SSO_PROVIDER.GOOGLE) { + return ; + } else if (provider.type === SSO_PROVIDER.LDAP) { + return ; + } else { + return ; + } }; return ( <> + {selectedLdapProvider && ( + { + setLdapModalOpened(false); + setSelectedLdapProvider(null); + }} + provider={selectedLdapProvider} + workspaceId={data.id} + /> + )} + {(isCloud() || data.hasLicenseKey) && ( <> @@ -31,13 +64,7 @@ export default function SsoLogin() {