From 81eb6157bc6566f317104f78075872e6d6e1f8e6 Mon Sep 17 00:00:00 2001 From: Salihu Date: Sat, 8 Aug 2026 01:12:43 +0100 Subject: [PATCH] tabs menu --- .../editor/components/tabs/tabs-menu.tsx | 151 ++++++++++++++++++ .../editor/components/tabs/tabs-view.tsx | 32 ++-- .../src/features/editor/page-editor.tsx | 2 + packages/editor-ext/src/lib/tabs/tabs.ts | 145 ++++++++++++++--- 4 files changed, 292 insertions(+), 38 deletions(-) create mode 100644 apps/client/src/features/editor/components/tabs/tabs-menu.tsx diff --git a/apps/client/src/features/editor/components/tabs/tabs-menu.tsx b/apps/client/src/features/editor/components/tabs/tabs-menu.tsx new file mode 100644 index 000000000..f5ffea2ff --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tabs-menu.tsx @@ -0,0 +1,151 @@ +import { BubbleMenu } from "@tiptap/react/menus"; +import React, { useCallback } from "react"; +import { EditorMenuProps, ShouldShowProps } from "../table/types/types"; +import { isEditorReady, isTextSelected } from "@docmost/editor-ext"; +import { Node as PMNode } from "@tiptap/pm/model"; +import { findParentNode, posToDOMRect } from "@tiptap/core"; +import classes from "../common/toolbar-menu.module.css"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { + IconChevronLeft, + IconChevronRight, + IconColumnInsertLeft, + IconColumnInsertRight, + IconColumnRemove, + IconTrashX, +} from "@tabler/icons-react"; + +const TabsMenu = React.memo(({ editor }: EditorMenuProps) => { + const { t } = useTranslation(); + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + if (isTextSelected(editor)) return false; + + return editor.isActive("tabs"); + }, + [editor] + ); + + const getReferencedVirtualElement = useCallback(() => { + if (!isEditorReady(editor)) return; + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "tabs"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const handleAddTabLeft = useCallback(() => { + editor.chain().focus().insertTab("left").run(); + }, [editor]); + + const handleAddTabRight = useCallback(() => { + editor.chain().focus().insertTab("right").run(); + }, [editor]); + + return ( + +
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + +
+ + ); +}); + +export default TabsMenu; diff --git a/apps/client/src/features/editor/components/tabs/tabs-view.tsx b/apps/client/src/features/editor/components/tabs/tabs-view.tsx index 0c0cf1b23..a02edca6c 100644 --- a/apps/client/src/features/editor/components/tabs/tabs-view.tsx +++ b/apps/client/src/features/editor/components/tabs/tabs-view.tsx @@ -6,9 +6,9 @@ import React, { useRef, useState, type KeyboardEvent, -} from 'react'; -import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'; -import { Tabs, TextInput } from '@mantine/core'; +} from "react"; +import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; +import { Tabs, TextInput } from "@mantine/core"; export default function TabsView(props: NodeViewProps) { const { node, editor, getPos } = props; @@ -22,14 +22,14 @@ export default function TabsView(props: NodeViewProps) { const labelId = node.child(index)?.attrs?.id; return { - label: labelText ?? '', + label: labelText ?? "", id: labelId ?? index, }; }); }, [node]); const activeTab = clampIndex(node.attrs.activeTab); - const [activeLabel, setActiveLabel] = useState(tabs[activeTab].label ?? ''); + const [activeLabel, setActiveLabel] = useState(tabs[activeTab].label ?? ""); useEffect(() => { setActiveLabel(tabs[activeTab].label); @@ -67,7 +67,7 @@ export default function TabsView(props: NodeViewProps) { setActiveLabel(label); if (label === tabs[activeTab].label) return; - if (typeof getPos === 'function') { + if (typeof getPos === "function") { editor.commands.updateTabLabel?.(activeTab, label, getPos()); } }, @@ -77,7 +77,7 @@ export default function TabsView(props: NodeViewProps) { const handleLabelKeyDown = useCallback( (event: KeyboardEvent) => { event.stopPropagation(); - if (event.key === 'Escape') { + if (event.key === "Escape") { event.preventDefault(); event.currentTarget.blur(); } @@ -86,7 +86,7 @@ export default function TabsView(props: NodeViewProps) { ); return ( - + {tabs.map(({ label, id }, index) => ( @@ -96,20 +96,20 @@ export default function TabsView(props: NodeViewProps) { onFocus={(event) => event.currentTarget.blur()} onClick={(e) => { e.preventDefault(); - if (typeof getPos === 'function') { + if (typeof getPos === "function") { editor.commands.setActiveTab?.(index, getPos()); } }} > @@ -126,15 +126,15 @@ export default function TabsView(props: NodeViewProps) { -
- +
+
); } const clampIndex = (value: unknown, length = Number.MAX_SAFE_INTEGER) => { - const parsed = typeof value === 'number' ? value : Number(value ?? 0); + const parsed = typeof value === "number" ? value : Number(value ?? 0); if (!Number.isFinite(parsed) || length <= 0) return 0; return Math.max(0, Math.min(Math.trunc(parsed), length - 1)); }; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 7149e7938..1450d85c1 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -82,6 +82,7 @@ import { getCollabSocket, releaseCollabSocket, } from "@/features/editor/collab-socket"; +import TabsMenu from "./components/tabs/tabs-menu"; interface PageEditorProps { pageId: string; @@ -453,6 +454,7 @@ function CollabPageEditor({ +
)} {editor && !editorIsEditable && (editable || canComment) && ( diff --git a/packages/editor-ext/src/lib/tabs/tabs.ts b/packages/editor-ext/src/lib/tabs/tabs.ts index 79c7e3c64..6c2f9e598 100644 --- a/packages/editor-ext/src/lib/tabs/tabs.ts +++ b/packages/editor-ext/src/lib/tabs/tabs.ts @@ -1,6 +1,6 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { Fragment, type Node as PMNode } from '@tiptap/pm/model'; -import { TextSelection, type EditorState } from '@tiptap/pm/state'; +import { TextSelection, Transaction, type EditorState } from '@tiptap/pm/state'; import { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'; import type { ComponentType } from 'react'; import { generateNodeId } from '../utils'; @@ -14,11 +14,12 @@ declare module '@tiptap/core' { interface Commands { tabs: { insertTabs: () => ReturnType; - setActiveTab: (index: number, tabsPos?: number) => ReturnType; + insertTab: (pos: string) => ReturnType; + setActiveTab: (index: number, tabsPos: number) => ReturnType; updateTabLabel: ( index: number, label: string, - tabsPos?: number, + tabsPos: number, ) => ReturnType; }; } @@ -85,6 +86,24 @@ export const Tabs = Node.create({ ]); }; + const resolveTabs = (state: EditorState) => { + const { $from } = state.selection; + let depth = $from.depth; + + while (depth >= 0) { + const node = $from.node(depth); + if (node.type.name === 'tabs') { + return { + node, + pos: $from.before(depth), + }; + } + depth--; + } + + return null; + }; + const resolveTarget = (state: EditorState, pos: number) => { const node = state.doc.nodeAt(pos); return { node, pos: pos }; @@ -95,6 +114,49 @@ export const Tabs = Node.create({ return pos.posAtIndex(tabIndex, pos.depth); }; + const applyActiveTabState = ( + tr: Transaction, + tabsPos: number, + previousIndex: number, + nextIndex: number, + ) => { + const tabsNode = tr.doc.nodeAt(tabsPos); + if (tabsNode?.type.name !== 'tabs' || tabsNode.childCount <= 0) { + return null; + } + + const prev = clampIndex(previousIndex, tabsNode.childCount); + const next = clampIndex(nextIndex, tabsNode.childCount); + + tr.setNodeMarkup(tabsPos, undefined, { + ...tabsNode.attrs, + activeTab: next, + }); + + const prevTabPos = getTabPos(tr.doc, tabsPos, prev); + const nextTabPos = getTabPos(tr.doc, tabsPos, next); + + if (prev !== next) { + const prevTabNode = tr.doc.nodeAt(prevTabPos); + if (prevTabNode) { + tr.setNodeMarkup(prevTabPos, undefined, { + ...prevTabNode.attrs, + active: false, + }); + } + } + + const nextTabNode = tr.doc.nodeAt(nextTabPos); + if (nextTabNode) { + tr.setNodeMarkup(nextTabPos, undefined, { + ...nextTabNode.attrs, + active: true, + }); + } + + return nextTabPos; + }; + return { insertTabs: () => @@ -128,6 +190,55 @@ export const Tabs = Node.create({ return true; }, + insertTab: + (pos: string) => + ({ state, tr, dispatch }) => { + const tabs = resolveTabs(state); + if (!tabs || tabs.node.childCount <= 0) return false; + + const currentTabIndex = clampIndex( + tabs.node.attrs.activeTab, + tabs.node.childCount, + ); + + const insertIndex = + pos === 'right' ? currentTabIndex + 1 : currentTabIndex; + + const newTab = createTab(state.schema, 'Tab', false); + if (!newTab) return false; + + const insertPos = getTabPos(state.doc, tabs.pos, insertIndex); + tr.insert(insertPos, newTab); + + const insertedTabPos = getTabPos(tr.doc, tabs.pos, insertIndex); + tr.setNodeMarkup(insertedTabPos, undefined, { + ...newTab.attrs, + active: true, + }); + + const previousActiveIndex = + pos === 'left' ? currentTabIndex + 1 : currentTabIndex; + + const activeTabPos = applyActiveTabState( + tr, + tabs.pos, + previousActiveIndex, + insertIndex, + ); + if (activeTabPos == null) return false; + + const tabNode = tr.doc.nodeAt(activeTabPos); + const labelSize = tabNode?.child(0).nodeSize ?? 0; + const panelContentPos = activeTabPos + 1 + labelSize + 1; + + tr.setSelection( + TextSelection.near(tr.doc.resolve(panelContentPos), 1), + ); + + if (dispatch) dispatch(tr); + return true; + }, + setActiveTab: (index, tabsPos) => ({ state, tr, dispatch }) => { @@ -140,27 +251,17 @@ export const Tabs = Node.create({ target.node.childCount, ); - const nextTab = getTabPos(state.doc, target.pos, nextIndex); - if (prevIndex !== nextIndex) { - const prevTab = getTabPos(state.doc, target.pos, prevIndex); + const activeTabPos = applyActiveTabState( + tr, + target.pos, + prevIndex, + nextIndex, + ); + if (activeTabPos == null) return false; - tr.setNodeMarkup(target.pos, undefined, { - ...target.node.attrs, - activeTab: nextIndex, - }); - tr.setNodeMarkup(prevTab, undefined, { - ...target.node.child(prevIndex).attrs, - active: false, - }); - tr.setNodeMarkup(nextTab, undefined, { - ...target.node.child(nextIndex).attrs, - active: true, - }); - } - - const tabNode = state.doc.nodeAt(nextTab); + const tabNode = tr.doc.nodeAt(activeTabPos); const labelSize = tabNode?.child(0).nodeSize ?? 0; - const panelContentPos = nextTab + 1 + labelSize + 1; + const panelContentPos = activeTabPos + 1 + labelSize + 1; tr.setSelection( TextSelection.near(tr.doc.resolve(panelContentPos), 1),