diff --git a/apps/api/plane/payment/views/enterprise.py b/apps/api/plane/payment/views/enterprise.py index aaa0c61a35..cb0721e2fe 100644 --- a/apps/api/plane/payment/views/enterprise.py +++ b/apps/api/plane/payment/views/enterprise.py @@ -487,23 +487,23 @@ class EnterpriseLicenseActivateUploadEndpoint(BaseAPIView): ) # Get all active workspace members - workspace_members = ( - WorkspaceMember.objects.filter(is_active=True, member__is_bot=False) + users = ( + User.objects.filter(is_active=True, is_bot=False) .annotate( - user_email=F("member__email"), - user_id=F("member__id"), - user_role=F("role"), + user_email=F("email"), + user_id=F("id"), + user_role=Value(20), ) .values("user_email", "user_id", "user_role") ) # Convert user_id to string - for member in workspace_members: - member["user_id"] = str(member["user_id"]) + for user in users: + user["user_id"] = str(user["user_id"]) # Prepare form data form_data = { - "members_list": json.dumps(list(workspace_members)), + "members_list": json.dumps(list(users)), } new_file = ContentFile(file_content, name=file.name) diff --git a/apps/monitor/lib/api/airgapped_api.go b/apps/monitor/lib/api/airgapped_api.go index e356ec9c19..60a4548939 100644 --- a/apps/monitor/lib/api/airgapped_api.go +++ b/apps/monitor/lib/api/airgapped_api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "sort" "strings" "time" @@ -219,6 +220,36 @@ func (a *AirgappedPrimeApi) fixTimestampFormat(jsonStr string) string { return jsonStr } +// activateMembersBySeats takes a list of members and the number of available seats, +// sorts members by role (higher roles first), and marks the first N members as active +// where N is the number of seats. Remaining members are marked as inactive. +func (a *AirgappedPrimeApi) activateMembersBySeats(members []WorkspaceMember, seats int) []WorkspaceMember { + if len(members) == 0 { + return members + } + + // Create a copy to avoid modifying the original slice + result := make([]WorkspaceMember, len(members)) + copy(result, members) + + // Sort by role in descending order (higher role = higher priority) + // Typically: Admin (20) > Member (15) > Guest (5) + sort.Slice(result, func(i, j int) bool { + return result[i].UserRole > result[j].UserRole + }) + + // Activate members up to seat limit, deactivate the rest + for i := range result { + if i < seats { + result[i].IsActive = true + } else { + result[i].IsActive = false + } + } + + return result +} + /* We provide a non nil error here, as the route handler handles the deactivate as if the deactivation is successfull, it will proceed to delete the license from the database @@ -309,11 +340,15 @@ func (a *AirgappedPrimeApi) SyncEnterpriseLicense(payload EnterpriseLicenseSyncP } } - // Step 4: Set workspace information and return response + // Step 3: Set workspace information licensePayload.WorkspaceID = uuid.New().String() licensePayload.WorkspaceSlug = uuid.New().String() - licensePayload.WorkspaceActivationResponse.MemberList = payload.MembersList + // Step 4: Activate members based on available seats + // Sort by role (admins first) and activate up to the seat limit + seats := licensePayload.WorkspaceActivationResponse.Seats + activatedMembers := a.activateMembersBySeats(payload.MembersList, seats) + licensePayload.WorkspaceActivationResponse.MemberList = activatedMembers // Initialize the response response := &EnterpriseLicenseActivateResponse{ diff --git a/apps/web/core/components/issues/issue-modal/base.tsx b/apps/web/core/components/issues/issue-modal/base.tsx index d6c9b4cceb..64d439f1d7 100644 --- a/apps/web/core/components/issues/issue-modal/base.tsx +++ b/apps/web/core/components/issues/issue-modal/base.tsx @@ -12,7 +12,7 @@ */ import { useEffect, useRef, useState } from "react"; -import { xor } from "lodash-es"; +import { isEqual, xor } from "lodash-es"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // Plane imports @@ -277,6 +277,63 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod } }; + const handleCycleChange = async (data: Partial | undefined, payload: Partial) => { + if (!workspaceSlug || !data?.project_id || !data?.id) return; + // return if user is not trying to change the cycle, i.e + // - cycle_id is not present in payload + // - cycle_id is the same as the current cycle id + if (!("cycle_id" in payload) || isEqual(data?.cycle_id, payload.cycle_id)) return; + + // Removing the cycle + const currentCycleId = data?.cycle_id; + if (currentCycleId && payload.cycle_id === null) { + await issues.removeIssueFromCycle(workspaceSlug, data.project_id, currentCycleId, data.id); + fetchCycleDetails(workspaceSlug, data.project_id, currentCycleId).catch((error) => { + console.error(error); + }); + } + + // Adding the cycle + const newCycleId = payload.cycle_id; + if ( + newCycleId && + newCycleId !== "" && + (payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE) // For cycle store, we don't need to manually attach the work item to the cycle + ) { + await addIssueToCycle(data as TBaseIssue, newCycleId); + } + }; + + const handleModuleChange = async (data: Partial, payload: Partial) => { + if (!workspaceSlug || !data?.project_id || !data?.id) return; + // return if user is not trying to change the module, i.e + // - module_ids is not present in payload + // - module_ids is not an array + // - module_ids is the same as the current module ids + if ( + !("module_ids" in payload) || + !Array.isArray(payload.module_ids) || + isEqual(data?.module_ids, payload.module_ids) + ) + return; + + const updatedModuleIds = xor(data.module_ids, payload.module_ids); + const modulesToAdd: string[] = []; + const modulesToRemove: string[] = []; + + for (const moduleId of updatedModuleIds) { + if (data.module_ids?.includes(moduleId)) { + modulesToRemove.push(moduleId); + } else { + modulesToAdd.push(moduleId); + } + } + // update modules if there are modules to add or remove + if (modulesToAdd.length > 0 || modulesToRemove.length > 0) { + await issues.changeModulesInIssue(workspaceSlug, data.project_id, data.id, modulesToAdd, modulesToRemove); + } + }; + const handleUpdateIssue = async ( payload: Partial, showToast: boolean = true @@ -296,48 +353,20 @@ export const CreateUpdateIssueModalBase = observer(function CreateUpdateIssueMod if (isDraft) await draftIssues.updateIssue(workspaceSlug.toString(), data.id, payload); else if (updateIssue) await updateIssue(payload.project_id, data.id, payload); - // check if we should add/remove issue to/from cycle - if ( - payload.cycle_id && - payload.cycle_id !== "" && - (payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE) - ) { - await addIssueToCycle(data as TBaseIssue, payload.cycle_id); - } - if (data.cycle_id && !payload.cycle_id && data.project_id) { - await issues.removeIssueFromCycle(workspaceSlug.toString(), data.project_id, data.cycle_id, data.id); - fetchCycleDetails(workspaceSlug.toString(), data.project_id, data.cycle_id); - } - - if (data.module_ids && payload.module_ids && data.project_id) { - const updatedModuleIds = xor(data.module_ids, payload.module_ids); - const modulesToAdd: string[] = []; - const modulesToRemove: string[] = []; - - for (const moduleId of updatedModuleIds) { - if (data.module_ids.includes(moduleId)) { - modulesToRemove.push(moduleId); - } else { - modulesToAdd.push(moduleId); - } - } - await issues.changeModulesInIssue( - workspaceSlug.toString(), - data.project_id, - data.id, - modulesToAdd, - modulesToRemove - ); - } - - // add other property values - await handleCreateUpdatePropertyValues({ - issueId: data.id, - issueTypeId: payload.type_id, - projectId: payload.project_id, - workspaceSlug: workspaceSlug?.toString(), - isDraft: isDraft, - }); + await Promise.all([ + // handle cycle change + handleCycleChange(data, payload), + // handle module change + handleModuleChange(data, payload), + // handle other property values + handleCreateUpdatePropertyValues({ + issueId: data.id, + issueTypeId: payload.type_id, + projectId: payload.project_id, + workspaceSlug: workspaceSlug?.toString(), + isDraft: isDraft, + }), + ]); if (showToast) { setToast({ diff --git a/apps/web/ee/components/issues/issue-details/issue-type-switcher.tsx b/apps/web/ee/components/issues/issue-details/issue-type-switcher.tsx index fd7a34dd9a..1dd808c7fe 100644 --- a/apps/web/ee/components/issues/issue-details/issue-type-switcher.tsx +++ b/apps/web/ee/components/issues/issue-details/issue-type-switcher.tsx @@ -12,6 +12,7 @@ */ import { useState } from "react"; +import { toJS } from "mobx"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { ArrowRightLeft } from "lucide-react"; @@ -54,7 +55,7 @@ export const IssueTypeSwitcher = observer(function IssueTypeSwitcher(props: TIss const handleEditIssue = () => { if (disabled) return; - setIssueToEdit(issue); + setIssueToEdit(toJS(issue)); setIsCreateUpdateIssueModalOpen(true); toggleCreateIssueModal(true); }; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts new file mode 100644 index 0000000000..f3f00d43fe --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.ts @@ -0,0 +1,459 @@ +import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom"; +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// icons +import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons"; +// dropdown +import { createDropdownContent, handleDropdownAction } from "../dropdown-content"; +import type { DropdownOption } from "../dropdown-content"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectColumn, + getSelectedColumns, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedColumns, duplicateColumns } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getDropMarker, + getColDragMarker, + hideDragMarker, + hideDropMarker, + updateColDragMarker, + updateColDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils"; + +export type ColumnDragHandleConfig = { + editor: Editor; + col: number; +}; + +/** + * Creates a vanilla JS column drag handle element with dropdown functionality + */ +export function createColumnDragHandle(config: ColumnDragHandleConfig): { + element: HTMLElement; + destroy: () => void; +} { + const { editor, col } = config; + + // Create container + const container = document.createElement("div"); + container.className = + "table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2"; + + // Create button + const button = document.createElement("button"); + button.type = "button"; + button.className = "default-state"; + + // Create icon (Ellipsis lucide icon as SVG) + const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary"); + + button.appendChild(icon); + container.appendChild(button); + + // State for dropdown + let isDropdownOpen = false; + let dropdownElement: HTMLElement | null = null; + let backdropElement: HTMLElement | null = null; + let cleanupFloating: (() => void) | null = null; + let backdropClickHandler: (() => void) | null = null; + + // Track drag event listeners for cleanup + let dragListeners: { + mouseup?: (e: MouseEvent) => void; + mousemove?: (e: MouseEvent) => void; + } = {}; + + // Dropdown toggle function + const toggleDropdown = () => { + if (isDropdownOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }; + + const closeDropdown = () => { + if (!isDropdownOpen) return; + + isDropdownOpen = false; + + // Reset button to default state + button.className = "default-state"; + + // Remove dropdown and backdrop + if (dropdownElement) { + dropdownElement.remove(); + dropdownElement = null; + } + if (backdropElement) { + // Remove backdrop listener before removing element + if (backdropClickHandler) { + backdropElement.removeEventListener("click", backdropClickHandler); + backdropClickHandler = null; + } + backdropElement.remove(); + backdropElement = null; + } + + // Cleanup floating UI (this also removes keydown listener) + if (cleanupFloating) { + cleanupFloating(); + cleanupFloating = null; + } + + // Remove active dropdown extension + setTimeout(() => { + editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + }, 0); + }; + + const openDropdown = () => { + if (isDropdownOpen) return; + + isDropdownOpen = true; + + // Update button to open state + button.className = "open-state"; + + // Add active dropdown extension + editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + + // Create backdrop + backdropElement = document.createElement("div"); + backdropElement.style.position = "fixed"; + backdropElement.style.inset = "0"; + backdropElement.style.zIndex = "99"; + backdropClickHandler = closeDropdown; + backdropElement.addEventListener("click", backdropClickHandler); + document.body.appendChild(backdropElement); + + // Create dropdown + dropdownElement = document.createElement("div"); + dropdownElement.className = + "max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"; + dropdownElement.style.position = "fixed"; + dropdownElement.style.zIndex = "100"; + + // Create and append dropdown content + const content = createDropdownContent(getDropdownOptions()); + dropdownElement.appendChild(content); + + document.body.appendChild(dropdownElement); + + // Attach dropdown event listeners + attachDropdownEventListeners(dropdownElement); + + // Setup floating UI positioning + cleanupFloating = autoUpdate(button, dropdownElement, () => { + void computePosition(button, dropdownElement!, { + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + }).then(({ x, y }) => { + if (dropdownElement) { + dropdownElement.style.left = `${x}px`; + dropdownElement.style.top = `${y}px`; + } + return; + }); + }); + + // Handle keyboard events + const handleKeyDown = (event: KeyboardEvent) => { + closeDropdown(); + event.preventDefault(); + event.stopPropagation(); + }; + document.addEventListener("keydown", handleKeyDown); + + // Store cleanup for this specific dropdown instance + const originalCleanup = cleanupFloating; + cleanupFloating = () => { + document.removeEventListener("keydown", handleKeyDown); + if (originalCleanup) originalCleanup(); + }; + }; + + const getDropdownOptions = (): DropdownOption[] => [ + { + key: "toggle-header", + label: "Header column", + icon: DRAG_HANDLE_ICONS.toggleRight, + showRightIcon: true, + }, + { + key: "insert-left", + label: "Insert left", + icon: DRAG_HANDLE_ICONS.arrowLeft, + showRightIcon: false, + }, + { + key: "insert-right", + label: "Insert right", + icon: DRAG_HANDLE_ICONS.arrowRight, + showRightIcon: false, + }, + { + key: "duplicate", + label: "Duplicate", + icon: DRAG_HANDLE_ICONS.duplicate, + showRightIcon: false, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: DRAG_HANDLE_ICONS.close, + showRightIcon: false, + }, + { + key: "delete", + label: "Delete", + icon: DRAG_HANDLE_ICONS.trash, + showRightIcon: false, + }, + ]; + + const attachDropdownEventListeners = (dropdown: HTMLElement) => { + const buttons = dropdown.querySelectorAll("button[data-action]"); + const colorPanel = dropdown.querySelector(".color-panel"); + const colorChevron = dropdown.querySelector(".color-chevron"); + + buttons.forEach((btn) => { + const action = btn.getAttribute("data-action"); + if (!action) return; + + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Handle common actions + handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron); + + // Handle column-specific actions + switch (action) { + case "toggle-header": + editor.chain().focus().toggleHeaderColumn().run(); + closeDropdown(); + break; + case "set-bg-color": { + const color = btn.getAttribute("data-color"); + if (color) { + editor + .chain() + .focus() + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + background: color, + }) + .run(); + } + closeDropdown(); + break; + } + case "insert-left": + editor.chain().focus().addColumnBefore().run(); + closeDropdown(); + break; + case "insert-right": + editor.chain().focus().addColumnAfter().run(); + closeDropdown(); + break; + case "duplicate": { + const table = findTable(editor.state.selection); + if (table) { + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedColumns = getSelectedColumns(editor.state.selection, tableMap); + tr = duplicateColumns(table, selectedColumns, tr); + editor.view.dispatch(tr); + } + closeDropdown(); + break; + } + case "clear-contents": + editor.chain().focus().clearSelectedCells().run(); + closeDropdown(); + break; + case "delete": + editor.chain().focus().deleteColumn().run(); + closeDropdown(); + break; + } + }); + }); + }; + + // Handle mousedown for dragging + const handleMouseDown = (e: MouseEvent) => { + // Prevent dropdown from opening during drag + if (e.button !== 0) return; // Only left click + + e.stopPropagation(); + e.preventDefault(); + + // Check if this is a click (will be determined by mouseup without much movement) + const startX = e.clientX; + const startY = e.clientY; + let hasMoved = false; + + // Clean up any existing drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + dragListeners = {}; + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectColumn(table, col, editor.state.tr)); + + // Drag column logic + const tableWidthPx = getTableWidthPx(table, editor); + const columns = getTableColumnNodesInfo(table, editor); + + let dropIndex = col; + const startLeft = columns[col].left ?? 0; + const startXPos = e.clientX; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null; + + const handleFinish = (): void => { + // Clean up markers if they exist + if (dropMarker && dragMarker) { + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + } + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + // Perform drag operation if user moved + if (col !== dropIndex && hasMoved) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedColumns(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + dragListeners.mouseup = undefined; + dragListeners.mousemove = undefined; + + // If it was just a click (no movement), toggle dropdown + if (!hasMoved) { + toggleDropdown(); + } + }; + + let pseudoColumn: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent): void => { + // Mark that we've moved + const deltaX = Math.abs(moveEvent.clientX - startX); + const deltaY = Math.abs(moveEvent.clientY - startY); + if (deltaX > 3 || deltaY > 3) { + hasMoved = true; + } + + // Calculate drop index + const currentLeft = startLeft + moveEvent.clientX - startXPos; + dropIndex = calculateColumnDropIndex(col, columns, currentLeft); + + // Update visual markers if they exist + if (dropMarker && dragMarker) { + if (!pseudoColumn) { + pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table); + const tableHeightPx = getTableHeightPx(table, editor); + if (pseudoColumn) { + pseudoColumn.style.height = `${tableHeightPx}px`; + } + } + + const dragMarkerWidthPx = columns[col].width; + const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx)); + const dropMarkerLeftPx = + dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width; + + updateColDropMarker({ + element: dropMarker, + left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1, + width: DROP_MARKER_THICKNESS, + }); + updateColDragMarker({ + element: dragMarker, + left: dragMarkerLeftPx, + width: dragMarkerWidthPx, + pseudoColumn, + }); + } + }; + + try { + dragListeners.mouseup = handleFinish; + dragListeners.mousemove = handleMove; + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in ColumnDragHandle:", error); + handleFinish(); + } + }; + + // Attach mousedown listener + button.addEventListener("mousedown", handleMouseDown); + + // Cleanup function + const destroy = () => { + // Close dropdown if open + if (isDropdownOpen) { + closeDropdown(); + } + + // Remove drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + + // Remove mousedown listener + button.removeEventListener("mousedown", handleMouseDown); + + // Remove DOM elements + container.remove(); + }; + + return { + element: container, + destroy, + }; +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx deleted file mode 100644 index aa57272bac..0000000000 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx +++ /dev/null @@ -1,271 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023-present Plane Software, Inc. - * SPDX-License-Identifier: LicenseRef-Plane-Commercial - * - * Licensed under the Plane Commercial License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * https://plane.so/legals/eula - * - * DO NOT remove or modify this notice. - * NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. - */ - -import { - shift, - flip, - useDismiss, - useFloating, - useInteractions, - autoUpdate, - useClick, - useRole, - FloatingOverlay, - FloatingPortal, -} from "@floating-ui/react"; -import type { Editor } from "@tiptap/core"; -import { Ellipsis } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -// plane imports -import { cn } from "@plane/utils"; -// constants -import { CORE_EXTENSIONS } from "@/constants/extension"; -// extensions -import { - findTable, - getTableHeightPx, - getTableWidthPx, - isCellSelection, - selectColumn, -} from "@/extensions/table/table/utilities/helpers"; -// local imports -import { moveSelectedColumns } from "../actions"; -import { - DROP_MARKER_THICKNESS, - getColDragMarker, - getDropMarker, - hideDragMarker, - hideDropMarker, - updateColDragMarker, - updateColDropMarker, -} from "../marker-utils"; -import { updateCellContentVisibility } from "../utils"; -import { ColumnOptionsDropdown } from "./dropdown"; -import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils"; - -export type ColumnDragHandleProps = { - col: number; - editor: Editor; -}; - -export function ColumnDragHandle(props: ColumnDragHandleProps) { - const { col, editor } = props; - // states - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - // Track active event listeners for cleanup - const activeListenersRef = useRef<{ - mouseup?: (e: MouseEvent) => void; - mousemove?: (e: MouseEvent) => void; - }>({}); - - // Cleanup window event listeners on unmount - useEffect(() => { - const listenersRef = activeListenersRef.current; - return () => { - // Remove any lingering window event listeners when component unmounts - if (listenersRef.mouseup) { - window.removeEventListener("mouseup", listenersRef.mouseup); - } - if (listenersRef.mousemove) { - window.removeEventListener("mousemove", listenersRef.mousemove); - } - }; - }, []); - // floating ui - const { refs, floatingStyles, context } = useFloating({ - placement: "bottom-start", - middleware: [ - flip({ - fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], - }), - shift({ - padding: 8, - }), - ], - open: isDropdownOpen, - onOpenChange: (open) => { - setIsDropdownOpen(open); - if (open) { - editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - } else { - setTimeout(() => { - editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - }, 0); - } - }, - whileElementsMounted: autoUpdate, - }); - const click = useClick(context); - const dismiss = useDismiss(context); - const role = useRole(context); - const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); - - useEffect(() => { - if (!isDropdownOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { - context.onOpenChange(false); - event.preventDefault(); - event.stopPropagation(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isDropdownOpen, context]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - - // Prevent multiple simultaneous drag operations - // If there are already listeners attached, remove them first - if (activeListenersRef.current.mouseup) { - window.removeEventListener("mouseup", activeListenersRef.current.mouseup); - } - if (activeListenersRef.current.mousemove) { - window.removeEventListener("mousemove", activeListenersRef.current.mousemove); - } - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - - const table = findTable(editor.state.selection); - if (!table) return; - - editor.view.dispatch(selectColumn(table, col, editor.state.tr)); - - // drag column - const tableWidthPx = getTableWidthPx(table, editor); - const columns = getTableColumnNodesInfo(table, editor); - - let dropIndex = col; - const startLeft = columns[col].left ?? 0; - const startX = e.clientX; - const tableElement = editor.view.nodeDOM(table.pos); - - const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; - const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null; - - const handleFinish = () => { - if (!dropMarker || !dragMarker) return; - hideDropMarker(dropMarker); - hideDragMarker(dragMarker); - - if (isCellSelection(editor.state.selection)) { - updateCellContentVisibility(editor, false); - } - - if (col !== dropIndex) { - let tr = editor.state.tr; - const selection = editor.state.selection; - if (isCellSelection(selection)) { - const table = findTable(selection); - if (table) { - tr = moveSelectedColumns(editor, table, selection, dropIndex, tr); - } - } - editor.view.dispatch(tr); - } - window.removeEventListener("mouseup", handleFinish); - window.removeEventListener("mousemove", handleMove); - // Clear the ref - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - }; - - let pseudoColumn: HTMLElement | undefined; - - const handleMove = (moveEvent: MouseEvent) => { - if (!dropMarker || !dragMarker) return; - const currentLeft = startLeft + moveEvent.clientX - startX; - dropIndex = calculateColumnDropIndex(col, columns, currentLeft); - - if (!pseudoColumn) { - pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table); - const tableHeightPx = getTableHeightPx(table, editor); - if (pseudoColumn) { - pseudoColumn.style.height = `${tableHeightPx}px`; - } - } - - const dragMarkerWidthPx = columns[col].width; - const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx)); - const dropMarkerLeftPx = - dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width; - - updateColDropMarker({ - element: dropMarker, - left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1, - width: DROP_MARKER_THICKNESS, - }); - updateColDragMarker({ - element: dragMarker, - left: dragMarkerLeftPx, - width: dragMarkerWidthPx, - pseudoColumn, - }); - }; - - try { - // Store references for cleanup - activeListenersRef.current.mouseup = handleFinish; - activeListenersRef.current.mousemove = handleMove; - window.addEventListener("mouseup", handleFinish); - window.addEventListener("mousemove", handleMove); - } catch (error) { - console.error("Error in ColumnDragHandle:", error); - handleFinish(); - } - }, - [col, editor] - ); - - return ( - <> -
- -
- {isDropdownOpen && ( - - {/* Backdrop */} - -
- context.onOpenChange(false)} /> -
-
- )} - - ); -} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts index f72bddcf99..24dfb15513 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts @@ -15,7 +15,6 @@ import type { Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { TableMap } from "@tiptap/pm/tables"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { ReactRenderer } from "@tiptap/react"; // extensions import { findTable, @@ -23,16 +22,20 @@ import { haveTableRelatedChanges, } from "@/extensions/table/table/utilities/helpers"; // local imports -import type { ColumnDragHandleProps } from "./drag-handle"; -import { ColumnDragHandle } from "./drag-handle"; +import { createColumnDragHandle } from "./drag-handle"; + +type DragHandleInstance = { + element: HTMLElement; + destroy: () => void; +}; type TableColumnDragHandlePluginState = { decorations?: DecorationSet; // track table structure to detect changes tableWidth?: number; tableNodePos?: number; - // track renderers for cleanup - renderers?: ReactRenderer[]; + // track drag handle instances for cleanup + dragHandles?: DragHandleInstance[]; }; const TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableColumnHandlerDecorationPlugin"); @@ -73,43 +76,40 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin { + // Clean up old drag handles before creating new ones + prev.dragHandles?.forEach((handle) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); // recreate all decorations const decorations: Decoration[] = []; - const renderers: ReactRenderer[] = []; + const dragHandles: DragHandleInstance[] = []; for (let col = 0; col < tableMap.width; col++) { const pos = getTableCellWidgetDecorationPos(table, tableMap, col); - const dragHandleComponent = new ReactRenderer(ColumnDragHandle, { - props: { - col, - editor, - } satisfies ColumnDragHandleProps, + const dragHandle = createColumnDragHandle({ editor, + col, }); - renderers.push(dragHandleComponent); - decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + dragHandles.push(dragHandle); + decorations.push(Decoration.widget(pos, () => dragHandle.element)); } return { decorations: DecorationSet.create(newState.doc, decorations), tableWidth: tableMap.width, tableNodePos: table.pos, - renderers, + dragHandles, }; }, }, @@ -120,15 +120,15 @@ export const TableColumnDragHandlePlugin = (editor: Editor): Plugin { + state?.dragHandles?.forEach((handle: DragHandleInstance) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); }, diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/dropdown-content.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/dropdown-content.ts new file mode 100644 index 0000000000..46eb53f94b --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/dropdown-content.ts @@ -0,0 +1,179 @@ +import type { Editor } from "@tiptap/core"; +// constants +import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; +// icons +import { DRAG_HANDLE_ICONS, createSvgElement } from "./icons"; + +export type DropdownOption = { + key: string; + label: string; + icon: string; + showRightIcon: boolean; +}; + +/** + * Creates the color selector section for the dropdown + */ +export function createColorSelector(): HTMLElement { + const container = document.createElement("div"); + container.className = "mb-2"; + + // Create toggle button + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = + "flex items-center justify-between gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover"; + toggleBtn.setAttribute("data-action", "toggle-color-selector"); + + // Left span with icon and text + const leftSpan = document.createElement("span"); + leftSpan.className = "flex items-center gap-2"; + const paletteIcon = createSvgElement(DRAG_HANDLE_ICONS.palette, "shrink-0 size-3"); + leftSpan.appendChild(paletteIcon); + leftSpan.appendChild(document.createTextNode("Color")); + + // Right chevron icon + const chevronIcon = createSvgElement( + DRAG_HANDLE_ICONS.chevronRight, + "shrink-0 size-3 transition-transform duration-200 color-chevron" + ); + + toggleBtn.appendChild(leftSpan); + toggleBtn.appendChild(chevronIcon); + container.appendChild(toggleBtn); + + // Create color panel + const colorPanel = document.createElement("div"); + colorPanel.className = "p-1 space-y-2 mb-1.5 hidden color-panel"; + + const innerDiv = document.createElement("div"); + innerDiv.className = "space-y-1"; + + const title = document.createElement("p"); + title.className = "text-11 text-tertiary font-semibold"; + title.textContent = "Background colors"; + innerDiv.appendChild(title); + + const colorsContainer = document.createElement("div"); + colorsContainer.className = "flex items-center flex-wrap gap-2"; + + // Create color buttons + COLORS_LIST.forEach((color) => { + const colorBtn = document.createElement("button"); + colorBtn.type = "button"; + colorBtn.className = + "flex-shrink-0 size-6 rounded-sm border-[0.5px] border-strong-1 hover:opacity-60 transition-opacity"; + colorBtn.style.backgroundColor = color.backgroundColor; + colorBtn.setAttribute("data-action", "set-bg-color"); + colorBtn.setAttribute("data-color", color.backgroundColor); + colorsContainer.appendChild(colorBtn); + }); + + // Create clear color button + const clearBtn = document.createElement("button"); + clearBtn.type = "button"; + clearBtn.className = + "flex-shrink-0 size-6 grid place-items-center rounded-sm text-tertiary border-[0.5px] border-strong-1 hover:bg-layer-transparent-hover transition-colors"; + clearBtn.setAttribute("data-action", "clear-bg-color"); + const banIcon = createSvgElement(DRAG_HANDLE_ICONS.ban, "size-4"); + clearBtn.appendChild(banIcon); + colorsContainer.appendChild(clearBtn); + + innerDiv.appendChild(colorsContainer); + colorPanel.appendChild(innerDiv); + container.appendChild(colorPanel); + + return container; +} + +/** + * Creates the dropdown content with options and color selector + */ +export function createDropdownContent(options: DropdownOption[]): DocumentFragment { + const fragment = document.createDocumentFragment(); + + // Create option buttons + options.forEach((option, index) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `flex items-center ${option.showRightIcon ? "justify-between" : ""} gap-2 w-full rounded-sm px-1 py-1.5 text-11 text-left truncate text-secondary hover:bg-layer-transparent-hover`; + btn.setAttribute("data-action", option.key); + + // Create icon element + const iconElement = createSvgElement(option.icon, "shrink-0 size-3"); + + // Create label + const labelDiv = document.createElement("div"); + labelDiv.className = "flex-grow truncate"; + labelDiv.textContent = option.label; + + // Append in correct order + if (option.showRightIcon) { + btn.appendChild(labelDiv); + btn.appendChild(iconElement); + } else { + btn.appendChild(iconElement); + btn.appendChild(labelDiv); + } + + fragment.appendChild(btn); + + // Add divider after first option (header toggle) + if (index === 0) { + const hr = document.createElement("hr"); + hr.className = "my-2 border-subtle"; + fragment.appendChild(hr); + + // Add color selector after divider + const colorSection = createColorSelector(); + fragment.appendChild(colorSection); + } + }); + + return fragment; +} + +/** + * Handles dropdown action events + */ +export function handleDropdownAction( + action: string, + editor: Editor, + onClose: () => void, + colorPanel?: Element | null, + colorChevron?: Element | null +): void { + switch (action) { + case "toggle-color-selector": + if (colorPanel && colorChevron) { + const isHidden = colorPanel.classList.contains("hidden"); + if (isHidden) { + colorPanel.classList.remove("hidden"); + colorChevron.classList.add("rotate-90"); + } else { + colorPanel.classList.add("hidden"); + colorChevron.classList.remove("rotate-90"); + } + } + break; + case "set-bg-color": { + // Color is handled by the button's data-color attribute + // This case is handled in the event listener + break; + } + case "clear-bg-color": + editor + .chain() + .focus() + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + background: null, + }) + .run(); + onClose(); + break; + default: + // Other actions are handled by specific implementations + break; + } +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/icons.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/icons.ts new file mode 100644 index 0000000000..37d33f06dc --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/icons.ts @@ -0,0 +1,49 @@ +/** + * SVG icon paths for table drag handle dropdowns + */ + +export const DRAG_HANDLE_ICONS = { + // Toggle icons + toggleRight: '', + + // Arrow icons + arrowUp: '', + arrowDown: '', + arrowLeft: '', + arrowRight: '', + + // Action icons + duplicate: + '', + close: '', + trash: + '', + + // Color icons + palette: + '', + chevronRight: '', + ban: '', + + // Ellipsis (drag handle button) + ellipsis: '', +} as const; + +/** + * Creates an SVG element (DOM element) with the given icon path + */ +export function createSvgElement(iconPath: string, className = "size-3"): SVGSVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", className); + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + svg.setAttribute("width", "24"); + svg.setAttribute("height", "24"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.innerHTML = iconPath; + return svg; +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts new file mode 100644 index 0000000000..754fe27c76 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.ts @@ -0,0 +1,458 @@ +import { computePosition, flip, shift, autoUpdate } from "@floating-ui/dom"; +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// icons +import { DRAG_HANDLE_ICONS, createSvgElement } from "../icons"; +// dropdown +import { createDropdownContent, handleDropdownAction } from "../dropdown-content"; +import type { DropdownOption } from "../dropdown-content"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectRow, + getSelectedRows, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedRows, duplicateRows } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getDropMarker, + getRowDragMarker, + hideDragMarker, + hideDropMarker, + updateRowDragMarker, + updateRowDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils"; + +export type RowDragHandleConfig = { + editor: Editor; + row: number; +}; + +/** + * Creates a vanilla JS row drag handle element with dropdown functionality + */ +export function createRowDragHandle(config: RowDragHandleConfig): { + element: HTMLElement; + destroy: () => void; +} { + const { editor, row } = config; + + // Create container + const container = document.createElement("div"); + container.className = + "table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2"; + + // Create button + const button = document.createElement("button"); + button.type = "button"; + button.className = "default-state"; + + // Create icon (Ellipsis lucide icon as SVG) + const icon = createSvgElement(DRAG_HANDLE_ICONS.ellipsis, "size-4 text-primary"); + + button.appendChild(icon); + container.appendChild(button); + + // State for dropdown + let isDropdownOpen = false; + let dropdownElement: HTMLElement | null = null; + let backdropElement: HTMLElement | null = null; + let cleanupFloating: (() => void) | null = null; + let backdropClickHandler: (() => void) | null = null; + + // Track drag event listeners for cleanup + let dragListeners: { + mouseup?: (e: MouseEvent) => void; + mousemove?: (e: MouseEvent) => void; + } = {}; + + // Dropdown toggle function + const toggleDropdown = () => { + if (isDropdownOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }; + + const closeDropdown = () => { + if (!isDropdownOpen) return; + + isDropdownOpen = false; + + // Reset button to default state + button.className = "default-state"; + + // Remove dropdown and backdrop + if (dropdownElement) { + dropdownElement.remove(); + dropdownElement = null; + } + if (backdropElement) { + // Remove backdrop listener before removing element + if (backdropClickHandler) { + backdropElement.removeEventListener("click", backdropClickHandler); + backdropClickHandler = null; + } + backdropElement.remove(); + backdropElement = null; + } + + // Cleanup floating UI (this also removes keydown listener) + if (cleanupFloating) { + cleanupFloating(); + cleanupFloating = null; + } + + // Remove active dropdown extension + setTimeout(() => { + editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + }, 0); + }; + + const openDropdown = () => { + if (isDropdownOpen) return; + + isDropdownOpen = true; + + // Update button to open state + button.className = "open-state"; + + // Add active dropdown extension + editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + + // Create backdrop + backdropElement = document.createElement("div"); + backdropElement.style.position = "fixed"; + backdropElement.style.inset = "0"; + backdropElement.style.zIndex = "99"; + backdropClickHandler = closeDropdown; + backdropElement.addEventListener("click", backdropClickHandler); + document.body.appendChild(backdropElement); + + // Create dropdown + dropdownElement = document.createElement("div"); + dropdownElement.className = + "max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200"; + dropdownElement.style.position = "fixed"; + dropdownElement.style.zIndex = "100"; + + // Create and append dropdown content + const content = createDropdownContent(getDropdownOptions()); + dropdownElement.appendChild(content); + + document.body.appendChild(dropdownElement); + + // Attach dropdown event listeners + attachDropdownEventListeners(dropdownElement); + + // Setup floating UI positioning + cleanupFloating = autoUpdate(button, dropdownElement, () => { + void computePosition(button, dropdownElement!, { + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + }).then(({ x, y }) => { + if (dropdownElement) { + dropdownElement.style.left = `${x}px`; + dropdownElement.style.top = `${y}px`; + } + return; + }); + }); + + // Handle keyboard events + const handleKeyDown = (event: KeyboardEvent) => { + closeDropdown(); + event.preventDefault(); + event.stopPropagation(); + }; + document.addEventListener("keydown", handleKeyDown); + + // Store cleanup for this specific dropdown instance + const originalCleanup = cleanupFloating; + cleanupFloating = () => { + document.removeEventListener("keydown", handleKeyDown); + if (originalCleanup) originalCleanup(); + }; + }; + + const getDropdownOptions = (): DropdownOption[] => [ + { + key: "toggle-header", + label: "Header row", + icon: DRAG_HANDLE_ICONS.toggleRight, + showRightIcon: true, + }, + { + key: "insert-above", + label: "Insert above", + icon: DRAG_HANDLE_ICONS.arrowUp, + showRightIcon: false, + }, + { + key: "insert-below", + label: "Insert below", + icon: DRAG_HANDLE_ICONS.arrowDown, + showRightIcon: false, + }, + { + key: "duplicate", + label: "Duplicate", + icon: DRAG_HANDLE_ICONS.duplicate, + showRightIcon: false, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: DRAG_HANDLE_ICONS.close, + showRightIcon: false, + }, + { + key: "delete", + label: "Delete", + icon: DRAG_HANDLE_ICONS.trash, + showRightIcon: false, + }, + ]; + + const attachDropdownEventListeners = (dropdown: HTMLElement) => { + const buttons = dropdown.querySelectorAll("button[data-action]"); + const colorPanel = dropdown.querySelector(".color-panel"); + const colorChevron = dropdown.querySelector(".color-chevron"); + + buttons.forEach((btn) => { + const action = btn.getAttribute("data-action"); + if (!action) return; + + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Handle common actions + handleDropdownAction(action, editor, closeDropdown, colorPanel, colorChevron); + + // Handle row-specific actions + switch (action) { + case "toggle-header": + editor.chain().focus().toggleHeaderRow().run(); + closeDropdown(); + break; + case "set-bg-color": { + const color = btn.getAttribute("data-color"); + if (color) { + editor + .chain() + .focus() + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + background: color, + }) + .run(); + } + closeDropdown(); + break; + } + case "insert-above": + editor.chain().focus().addRowBefore().run(); + closeDropdown(); + break; + case "insert-below": + editor.chain().focus().addRowAfter().run(); + closeDropdown(); + break; + case "duplicate": { + const table = findTable(editor.state.selection); + if (table) { + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedRows = getSelectedRows(editor.state.selection, tableMap); + tr = duplicateRows(table, selectedRows, tr); + editor.view.dispatch(tr); + } + closeDropdown(); + break; + } + case "clear-contents": + editor.chain().focus().clearSelectedCells().run(); + closeDropdown(); + break; + case "delete": + editor.chain().focus().deleteRow().run(); + closeDropdown(); + break; + } + }); + }); + }; + + // Handle mousedown for dragging + const handleMouseDown = (e: MouseEvent) => { + // Prevent dropdown from opening during drag + if (e.button !== 0) return; // Only left click + + e.stopPropagation(); + e.preventDefault(); + + // Check if this is a click (will be determined by mouseup without much movement) + const startX = e.clientX; + const startY = e.clientY; + let hasMoved = false; + + // Clean up any existing drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + dragListeners = {}; + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectRow(table, row, editor.state.tr)); + + // Drag row logic + const tableHeightPx = getTableHeightPx(table, editor); + const rows = getTableRowNodesInfo(table, editor); + + let dropIndex = row; + const startTop = rows[row].top ?? 0; + const startYPos = e.clientY; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null; + + const handleFinish = (): void => { + // Clean up markers if they exist + if (dropMarker && dragMarker) { + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + } + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + // Perform drag operation if user moved + if (row !== dropIndex && hasMoved) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedRows(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + dragListeners.mouseup = undefined; + dragListeners.mousemove = undefined; + + // If it was just a click (no movement), toggle dropdown + if (!hasMoved) { + toggleDropdown(); + } + }; + + let pseudoRow: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent): void => { + // Mark that we've moved + const deltaX = Math.abs(moveEvent.clientX - startX); + const deltaY = Math.abs(moveEvent.clientY - startY); + if (deltaX > 3 || deltaY > 3) { + hasMoved = true; + } + + // Calculate drop index + const cursorTop = startTop + moveEvent.clientY - startYPos; + dropIndex = calculateRowDropIndex(row, rows, cursorTop); + + // Update visual markers if they exist + if (dropMarker && dragMarker) { + if (!pseudoRow) { + pseudoRow = constructRowDragPreview(editor, editor.state.selection, table); + const tableWidthPx = getTableWidthPx(table, editor); + if (pseudoRow) { + pseudoRow.style.width = `${tableWidthPx}px`; + } + } + + const dragMarkerHeightPx = rows[row].height; + const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx)); + const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height; + + updateRowDropMarker({ + element: dropMarker, + top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2, + height: DROP_MARKER_THICKNESS, + }); + updateRowDragMarker({ + element: dragMarker, + top: dragMarkerTopPx, + height: dragMarkerHeightPx, + pseudoRow, + }); + } + }; + + try { + dragListeners.mouseup = handleFinish; + dragListeners.mousemove = handleMove; + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in RowDragHandle:", error); + handleFinish(); + } + }; + + // Attach mousedown listener + button.addEventListener("mousedown", handleMouseDown); + + // Cleanup function + const destroy = () => { + // Close dropdown if open + if (isDropdownOpen) { + closeDropdown(); + } + + // Remove drag listeners + if (dragListeners.mouseup) { + window.removeEventListener("mouseup", dragListeners.mouseup); + } + if (dragListeners.mousemove) { + window.removeEventListener("mousemove", dragListeners.mousemove); + } + + // Remove mousedown listener + button.removeEventListener("mousedown", handleMouseDown); + + // Remove DOM elements + container.remove(); + }; + + return { + element: container, + destroy, + }; +} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx deleted file mode 100644 index dfb2f0152f..0000000000 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx +++ /dev/null @@ -1,270 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023-present Plane Software, Inc. - * SPDX-License-Identifier: LicenseRef-Plane-Commercial - * - * Licensed under the Plane Commercial License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * https://plane.so/legals/eula - * - * DO NOT remove or modify this notice. - * NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. - */ - -import { - autoUpdate, - flip, - FloatingOverlay, - FloatingPortal, - shift, - useClick, - useDismiss, - useFloating, - useInteractions, - useRole, -} from "@floating-ui/react"; -import type { Editor } from "@tiptap/core"; -import { Ellipsis } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -// plane imports -import { cn } from "@plane/utils"; -// constants -import { CORE_EXTENSIONS } from "@/constants/extension"; -// extensions -import { - findTable, - getTableHeightPx, - getTableWidthPx, - isCellSelection, - selectRow, -} from "@/extensions/table/table/utilities/helpers"; -// local imports -import { moveSelectedRows } from "../actions"; -import { - DROP_MARKER_THICKNESS, - getDropMarker, - getRowDragMarker, - hideDragMarker, - hideDropMarker, - updateRowDragMarker, - updateRowDropMarker, -} from "../marker-utils"; -import { updateCellContentVisibility } from "../utils"; -import { RowOptionsDropdown } from "./dropdown"; -import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils"; - -export type RowDragHandleProps = { - editor: Editor; - row: number; -}; - -export function RowDragHandle(props: RowDragHandleProps) { - const { editor, row } = props; - // states - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - // Track active event listeners for cleanup - const activeListenersRef = useRef<{ - mouseup?: (e: MouseEvent) => void; - mousemove?: (e: MouseEvent) => void; - }>({}); - - // Cleanup window event listeners on unmount - useEffect(() => { - const listenersRef = activeListenersRef.current; - return () => { - // Remove any lingering window event listeners when component unmounts - if (listenersRef.mouseup) { - window.removeEventListener("mouseup", listenersRef.mouseup); - } - if (listenersRef.mousemove) { - window.removeEventListener("mousemove", listenersRef.mousemove); - } - }; - }, []); - // floating ui - const { refs, floatingStyles, context } = useFloating({ - placement: "bottom-start", - middleware: [ - flip({ - fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], - }), - shift({ - padding: 8, - }), - ], - open: isDropdownOpen, - onOpenChange: (open) => { - setIsDropdownOpen(open); - if (open) { - editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - } else { - setTimeout(() => { - editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); - }, 0); - } - }, - whileElementsMounted: autoUpdate, - }); - const click = useClick(context); - const dismiss = useDismiss(context); - const role = useRole(context); - const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); - - useEffect(() => { - if (!isDropdownOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { - context.onOpenChange(false); - event.preventDefault(); - event.stopPropagation(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isDropdownOpen, context]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - - // Prevent multiple simultaneous drag operations - // If there are already listeners attached, remove them first - if (activeListenersRef.current.mouseup) { - window.removeEventListener("mouseup", activeListenersRef.current.mouseup); - } - if (activeListenersRef.current.mousemove) { - window.removeEventListener("mousemove", activeListenersRef.current.mousemove); - } - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - - const table = findTable(editor.state.selection); - if (!table) return; - - editor.view.dispatch(selectRow(table, row, editor.state.tr)); - - // drag row - const tableHeightPx = getTableHeightPx(table, editor); - const rows = getTableRowNodesInfo(table, editor); - - let dropIndex = row; - const startTop = rows[row].top ?? 0; - const startY = e.clientY; - const tableElement = editor.view.nodeDOM(table.pos); - - const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; - const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null; - - const handleFinish = (): void => { - if (!dropMarker || !dragMarker) return; - hideDropMarker(dropMarker); - hideDragMarker(dragMarker); - - if (isCellSelection(editor.state.selection)) { - updateCellContentVisibility(editor, false); - } - - if (row !== dropIndex) { - let tr = editor.state.tr; - const selection = editor.state.selection; - if (isCellSelection(selection)) { - const table = findTable(selection); - if (table) { - tr = moveSelectedRows(editor, table, selection, dropIndex, tr); - } - } - editor.view.dispatch(tr); - } - window.removeEventListener("mouseup", handleFinish); - window.removeEventListener("mousemove", handleMove); - // Clear the ref - activeListenersRef.current.mouseup = undefined; - activeListenersRef.current.mousemove = undefined; - }; - - let pseudoRow: HTMLElement | undefined; - - const handleMove = (moveEvent: MouseEvent): void => { - if (!dropMarker || !dragMarker) return; - const cursorTop = startTop + moveEvent.clientY - startY; - dropIndex = calculateRowDropIndex(row, rows, cursorTop); - - if (!pseudoRow) { - pseudoRow = constructRowDragPreview(editor, editor.state.selection, table); - const tableWidthPx = getTableWidthPx(table, editor); - if (pseudoRow) { - pseudoRow.style.width = `${tableWidthPx}px`; - } - } - - const dragMarkerHeightPx = rows[row].height; - const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx)); - const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height; - - updateRowDropMarker({ - element: dropMarker, - top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2, - height: DROP_MARKER_THICKNESS, - }); - updateRowDragMarker({ - element: dragMarker, - top: dragMarkerTopPx, - height: dragMarkerHeightPx, - pseudoRow, - }); - }; - - try { - // Store references for cleanup - activeListenersRef.current.mouseup = handleFinish; - activeListenersRef.current.mousemove = handleMove; - window.addEventListener("mouseup", handleFinish); - window.addEventListener("mousemove", handleMove); - } catch (error) { - console.error("Error in RowDragHandle:", error); - handleFinish(); - } - }, - [editor, row] - ); - - return ( - <> -
- -
- {isDropdownOpen && ( - - {/* Backdrop */} - -
- context.onOpenChange(false)} /> -
-
- )} - - ); -} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts index 0820e37979..e8764fdec4 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts @@ -15,7 +15,6 @@ import type { Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { TableMap } from "@tiptap/pm/tables"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { ReactRenderer } from "@tiptap/react"; // extensions import { findTable, @@ -23,16 +22,20 @@ import { haveTableRelatedChanges, } from "@/extensions/table/table/utilities/helpers"; // local imports -import type { RowDragHandleProps } from "./drag-handle"; -import { RowDragHandle } from "./drag-handle"; +import { createRowDragHandle } from "./drag-handle"; + +type DragHandleInstance = { + element: HTMLElement; + destroy: () => void; +}; type TableRowDragHandlePluginState = { decorations?: DecorationSet; // track table structure to detect changes tableHeight?: number; tableNodePos?: number; - // track renderers for cleanup - renderers?: ReactRenderer[]; + // track drag handle instances for cleanup + dragHandles?: DragHandleInstance[]; }; const TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableRowDragHandlePlugin"); @@ -73,43 +76,40 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin { + // Clean up old drag handles before creating new ones + prev.dragHandles?.forEach((handle) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); // recreate all decorations const decorations: Decoration[] = []; - const renderers: ReactRenderer[] = []; + const dragHandles: DragHandleInstance[] = []; for (let row = 0; row < tableMap.height; row++) { const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width); - const dragHandleComponent = new ReactRenderer(RowDragHandle, { - props: { - editor, - row, - } satisfies RowDragHandleProps, + const dragHandle = createRowDragHandle({ editor, + row, }); - renderers.push(dragHandleComponent); - decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + dragHandles.push(dragHandle); + decorations.push(Decoration.widget(pos, () => dragHandle.element)); } return { decorations: DecorationSet.create(newState.doc, decorations), tableHeight: tableMap.height, tableNodePos: table.pos, - renderers, + dragHandles, }; }, }, @@ -120,15 +120,15 @@ export const TableRowDragHandlePlugin = (editor: Editor): Plugin { + state?.dragHandles?.forEach((handle: DragHandleInstance) => { try { - renderer.destroy(); + handle.destroy(); } catch (error) { - console.error("Error destroying renderer:", error); + console.error("Error destroying drag handle:", error); } }); }, diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts index 4e60af608d..57b465ac5b 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts @@ -85,6 +85,18 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { }); }; + let updateScheduled = false; + + const scheduleUpdate = () => { + if (updateScheduled) return; + updateScheduled = true; + + requestAnimationFrame(() => { + updateScheduled = false; + updateAllTables(); + }); + }; + return new Plugin({ key: TABLE_INSERT_PLUGIN_KEY, @@ -93,9 +105,9 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { return { update(view, prevState) { - // Update when document changes + // Debounce updates using RAF to batch multiple changes if (!prevState.doc.eq(view.state.doc)) { - updateAllTables(); + scheduleUpdate(); } }, destroy() { diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts index bb63b711e0..c5c2834bf5 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts @@ -232,16 +232,11 @@ export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTM export const findAllTables = (editor: Editor): TableInfo[] => { const tables: TableInfo[] = []; - const tableElements = editor.view.dom.querySelectorAll("table"); - tableElements.forEach((tableElement) => { - // Find the table's ProseMirror position - let tablePos = -1; - let tableNode: ProseMirrorNode | null = null; - - // Walk through the document to find matching table nodes - editor.state.doc.descendants((node, pos) => { - if (node.type.spec.tableRole === "table") { + // Iterate through document to look for tables + editor.state.doc.descendants((node, pos) => { + if (node.type.spec.tableRole === "table") { + try { const domAtPos = editor.view.domAtPos(pos + 1); let domTable = domAtPos.node; @@ -254,20 +249,17 @@ export const findAllTables = (editor: Editor): TableInfo[] => { domTable = domTable.parentNode; } - if (domTable === tableElement) { - tablePos = pos; - tableNode = node; - return false; // Stop iteration + if (domTable instanceof HTMLElement && domTable.tagName === "TABLE") { + tables.push({ + tableElement: domTable, + tableNode: node, + tablePos: pos, + }); } + } catch (error) { + // Skip tables that fail to resolve + console.error("Error finding table:", error); } - }); - - if (tablePos !== -1 && tableNode) { - tables.push({ - tableElement, - tableNode, - tablePos, - }); } }); diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index 5066204d40..72f8a2593d 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -57,7 +57,15 @@ .table-col-handle-container, .table-row-handle-container { & > button { - opacity: 0; + @apply py-1 opacity-0 rounded-sm outline-none; + + &.default-state { + @apply bg-layer-1 hover:bg-layer-1-hover border border-strong-1; + } + + &.open-state { + @apply opacity-100! bg-accent-primary border-accent-strong; + } } }