diff --git a/.gitignore b/.gitignore index 5081be86..c4e3cce3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies +.npmrc node_modules .pnp .pnp.js diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index aece53c9..ca924dd9 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -44,6 +44,15 @@ EMAIL_FEEDBACK_INBOX=feedback@mydomain.com # Provider: Resend EMAIL_RESEND_API_KEY= +# -------------------------- TIPTAP -------------------------- + +NEXT_PUBLIC_TIPTAP_PRO_TOKEN=zGK176JZbe9YRtUz7AV9xBDVxSVKfy9D2prmcSFnNEAe67vC5Elx3hh21XeiAaNz +NEXT_PUBLIC_TIPTAP_COLLAB_DOC_PREFIX= - Document prefix +NEXT_PUBLIC_TIPTAP_COLLAB_APP_ID= - Document Server App ID +NEXT_PUBLIC_TIPTAP_COLLAB_TOKEN= - JWT token for collaboration +NEXT_PUBLIC_TIPTAP_AI_APP_ID= - AI App ID +NEXT_PUBLIC_TIPTAP_AI_TOKEN= - JWT token for AI + # -------------------------- MONITORING -------------------------- # Select a monitoring provider: Console (default) or Sentry. diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx index 208f9647..ecd17d09 100644 --- a/apps/dashboard/app/layout.tsx +++ b/apps/dashboard/app/layout.tsx @@ -1,4 +1,6 @@ import '@workspace/ui/globals.css'; +import '@workspace/editor/styles/_variables.scss'; +import '@workspace/editor/styles/_keyframe-animations.scss'; import * as React from 'react'; import type { Metadata, Viewport } from 'next'; diff --git a/apps/dashboard/app/organizations/[slug]/(organization)/documents/[documentId]/page.tsx b/apps/dashboard/app/organizations/[slug]/(organization)/documents/[documentId]/page.tsx index 19bbabfc..e63a8b0f 100644 --- a/apps/dashboard/app/organizations/[slug]/(organization)/documents/[documentId]/page.tsx +++ b/apps/dashboard/app/organizations/[slug]/(organization)/documents/[documentId]/page.tsx @@ -14,7 +14,7 @@ import { import { DocumentActions } from '~/components/organizations/slug/documents/details/document-actions'; import { DocumentMeta } from '~/components/organizations/slug/documents/details/document-meta'; import { DocumentPageVisit } from '~/components/organizations/slug/documents/details/document-page-visit'; -import { DocumentTabs } from '~/components/organizations/slug/documents/details/document-tabs'; +import { DocumentEditor } from '~/components/organizations/slug/documents/details/editor/document-editor'; import { OrganizationPageTitle } from '~/components/organizations/slug/organization-page-title'; import { getDocument } from '~/data/documents/get-document'; import { createTitle } from '~/lib/formatters'; @@ -74,11 +74,11 @@ export default async function DocumentPage({ + - ); diff --git a/apps/dashboard/components/auth/forgot-password/forgot-password-card.tsx b/apps/dashboard/components/auth/forgot-password/forgot-password-card.tsx index 1b77bbd6..c6905b4b 100644 --- a/apps/dashboard/components/auth/forgot-password/forgot-password-card.tsx +++ b/apps/dashboard/components/auth/forgot-password/forgot-password-card.tsx @@ -76,10 +76,7 @@ export function ForgotPasswordCard({ }; return ( diff --git a/apps/dashboard/components/auth/sign-in/sign-in-card.tsx b/apps/dashboard/components/auth/sign-in/sign-in-card.tsx index 58e07b12..f79c6651 100644 --- a/apps/dashboard/components/auth/sign-in/sign-in-card.tsx +++ b/apps/dashboard/components/auth/sign-in/sign-in-card.tsx @@ -234,7 +234,7 @@ export function SignInCard({ > {methods.formState.isSubmitting ? (
- + Signing in...
) : ( diff --git a/apps/dashboard/components/auth/verify-email/verify-email-card.tsx b/apps/dashboard/components/auth/verify-email/verify-email-card.tsx index e2fda038..e25ffdde 100644 --- a/apps/dashboard/components/auth/verify-email/verify-email-card.tsx +++ b/apps/dashboard/components/auth/verify-email/verify-email-card.tsx @@ -45,13 +45,10 @@ export function VerifyEmailCard({ return ( - + Please check your email @@ -60,7 +57,7 @@ export function VerifyEmailCard({ a verification link. - +

Click the link in the email to verify your account.

diff --git a/apps/dashboard/components/organizations/slug/documents/details/document-actions-dropdown.tsx b/apps/dashboard/components/organizations/slug/documents/details/document-actions-dropdown.tsx index 7ddafd61..a8bc7d4f 100644 --- a/apps/dashboard/components/organizations/slug/documents/details/document-actions-dropdown.tsx +++ b/apps/dashboard/components/organizations/slug/documents/details/document-actions-dropdown.tsx @@ -70,17 +70,19 @@ export function DocumentActionsDropdown({ }; return ( - - - + + + Open menu + + } + /> +
+ + + + + { + return ; +} diff --git a/apps/dashboard/components/organizations/slug/documents/details/editor/document-editor.tsx b/apps/dashboard/components/organizations/slug/documents/details/editor/document-editor.tsx new file mode 100644 index 00000000..f1cbd0f8 --- /dev/null +++ b/apps/dashboard/components/organizations/slug/documents/details/editor/document-editor.tsx @@ -0,0 +1,28 @@ +'use client'; + +import * as React from 'react'; + +import { NusomaEditor } from '@workspace/editor'; +import { ResponsiveScrollArea } from '@workspace/ui/components/scroll-area'; +import { MediaQueries } from '@workspace/ui/lib/media-queries'; + +import type { DocumentDto } from '~/types/dtos/document-dto'; + +export type DocumentEditorProps = { + document: DocumentDto; +}; + +export function DocumentEditor({ + document +}: DocumentEditorProps): React.JSX.Element { + const slug = document.id; + return ( + + + + ); +} diff --git a/apps/dashboard/components/organizations/slug/documents/details/notes/document-note-card.tsx b/apps/dashboard/components/organizations/slug/documents/details/notes/document-note-card.tsx index 35c540cb..3fb58a7b 100644 --- a/apps/dashboard/components/organizations/slug/documents/details/notes/document-note-card.tsx +++ b/apps/dashboard/components/organizations/slug/documents/details/notes/document-note-card.tsx @@ -68,7 +68,7 @@ export function DocumentNoteCard({

{note.sender.name}

- + + + + + ); +}; + +const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({ + maxSize, + limit +}) => ( + <> +
+ + +
+ +
+
+ +
+ + Click to upload or drag and drop + + + Maximum {limit} file{limit === 1 ? '' : 's'}, {maxSize / 1024 / 1024}MB + each. + +
+ +); + +export const ImageUploadNode: React.FC = (props) => { + const { accept, limit, maxSize } = props.node.attrs; + const inputRef = useRef(null); + const extension = props.extension; + + const uploadOptions: UploadOptions = { + maxSize, + limit, + accept, + upload: extension.options.upload, + onSuccess: extension.options.onSuccess, + onError: extension.options.onError + }; + + const { fileItems, uploadFiles, removeFileItem, clearAllFiles } = + useFileUpload(uploadOptions); + + const handleUpload = async (files: File[]) => { + const urls = await uploadFiles(files); + + if (urls.length > 0) { + const pos = props.getPos(); + + if (isValidPosition(pos)) { + const imageNodes = urls.map((url, index) => { + const filename = + files[index]?.name.replace(/\.[^/.]+$/, '') || 'unknown'; + return { + type: extension.options.type, + attrs: { + ...extension.options, + src: url, + alt: filename, + title: filename + } + }; + }); + + props.editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + props.node.nodeSize }) + .insertContentAt(pos, imageNodes) + .run(); + + focusNextNode(props.editor); + } + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) { + extension.options.onError?.(new Error('No file selected')); + return; + } + handleUpload(Array.from(files)); + }; + + const handleClick = () => { + if (inputRef.current && fileItems.length === 0) { + inputRef.current.value = ''; + inputRef.current.click(); + } + }; + + const hasFiles = fileItems.length > 0; + + return ( + + {!hasFiles && ( + + + + )} + + {hasFiles && ( +
+ {fileItems.length > 1 && ( +
+ Uploading {fileItems.length} files + +
+ )} + {fileItems.map((fileItem) => ( + removeFileItem(fileItem.id)} + /> + ))} +
+ )} + + 1} + onChange={handleChange} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> +
+ ); +}; diff --git a/packages/editor/src/components/tiptap-node/image-upload-node/index.tsx b/packages/editor/src/components/tiptap-node/image-upload-node/index.tsx new file mode 100644 index 00000000..8d4d9201 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/image-upload-node/index.tsx @@ -0,0 +1 @@ +export * from './image-upload-node-extension'; diff --git a/packages/editor/src/components/tiptap-node/list-node/list-node.scss b/packages/editor/src/components/tiptap-node/list-node/list-node.scss new file mode 100644 index 00000000..d0fe5c8f --- /dev/null +++ b/packages/editor/src/components/tiptap-node/list-node/list-node.scss @@ -0,0 +1,160 @@ +.tiptap.ProseMirror { + --tt-checklist-bg-color: var(--tt-gray-light-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-light-a-900); + --tt-checklist-border-color: var(--tt-gray-light-a-200); + --tt-checklist-border-active-color: var(--tt-gray-light-a-900); + --tt-checklist-check-icon-color: var(--white); + --tt-checklist-text-active: var(--tt-gray-light-a-500); + + .dark & { + --tt-checklist-bg-color: var(--tt-gray-dark-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-border-color: var(--tt-gray-dark-a-200); + --tt-checklist-border-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-check-icon-color: var(--black); + --tt-checklist-text-active: var(--tt-gray-dark-a-500); + } +} + +/* ===================== + LISTS + ===================== */ +.tiptap.ProseMirror { + // Common list styles + ol, + ul { + margin-top: 1.5em; + margin-bottom: 1.5em; + padding-left: 1.5em; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + ol, + ul { + margin-top: 0; + margin-bottom: 0; + } + } + + li { + p { + margin-top: 0; + line-height: 1.6; + } + } + + // Ordered lists + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + } + } + } + + // Unordered lists + ul:not([data-type="taskList"]) { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + } + } + } + + // Task lists + ul[data-type="taskList"] { + padding-left: 0.25em; + + li { + display: flex; + flex-direction: row; + align-items: flex-start; + + &:not(:has(> p:first-child)) { + list-style-type: none; + } + + &[data-checked="true"] { + > div > p { + opacity: 0.5; + text-decoration: line-through; + } + + > div > p span { + text-decoration: line-through; + } + } + + label { + position: relative; + padding-top: 0.375rem; + padding-right: 0.5rem; + + input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + span { + display: block; + width: 1em; + height: 1em; + border: 1px solid var(--tt-checklist-border-color); + border-radius: var(--tt-radius-xs, 0.25rem); + position: relative; + cursor: pointer; + background-color: var(--tt-checklist-bg-color); + transition: + background-color 80ms ease-out, + border-color 80ms ease-out; + + &::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 0.75em; + height: 0.75em; + background-color: var(--tt-checklist-check-icon-color); + opacity: 0; + -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + } + } + + input[type="checkbox"]:checked + span { + background: var(--tt-checklist-bg-active-color); + border-color: var(--tt-checklist-border-active-color); + + &::before { + opacity: 1; + } + } + } + + div { + flex: 1 1 0%; + min-width: 0; + } + } + } +} diff --git a/packages/editor/src/components/tiptap-node/paragraph-node/paragraph-node.scss b/packages/editor/src/components/tiptap-node/paragraph-node/paragraph-node.scss new file mode 100644 index 00000000..7d4145a5 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/paragraph-node/paragraph-node.scss @@ -0,0 +1,273 @@ +.tiptap.ProseMirror { + --tt-collaboration-carets-label: var(--tt-gray-light-900); + --link-text-color: var(--tt-brand-color-500); + --thread-text: var(--tt-gray-light-900); + --placeholder-color: var(--tt-gray-light-a-400); + --thread-bg-color: var(--tt-color-yellow-inc-2); + + // ai + --tiptap-ai-insertion-color: var(--tt-brand-color-600); + + .dark & { + --tt-collaboration-carets-label: var(--tt-gray-dark-100); + --link-text-color: var(--tt-brand-color-400); + --thread-text: var(--tt-gray-dark-900); + --placeholder-color: var(--tt-gray-dark-a-400); + --thread-bg-color: var(--tt-color-yellow-dec-2); + + --tiptap-ai-insertion-color: var(--tt-brand-color-400); + } +} + +/* Ensure each top-level node has relative positioning + so absolutely positioned placeholders work correctly */ +.tiptap.ProseMirror > * { + position: relative; +} + +/* ===================== + CORE EDITOR STYLES + ===================== */ +.tiptap.ProseMirror { + white-space: pre-wrap; + outline: none; + caret-color: var(--tt-cursor-color); + + // Paragraph spacing + p:not(:first-child):not(td p):not(th p) { + font-size: 1rem; + line-height: 1.6; + font-weight: normal; + margin-top: 20px; + } + + // Selection styles + &:not(.readonly):not(.ProseMirror-hideselection) { + ::selection { + background-color: var(--tt-selection-color); + } + + .selection::selection { + background: transparent; + } + } + + .selection { + display: inline; + background-color: var(--tt-selection-color); + } + + // Selected node styles + .ProseMirror-selectednode:not(img):not(pre):not(.react-renderer) { + border-radius: var(--tt-radius-md); + background-color: var(--tt-selection-color); + } + + .ProseMirror-hideselection { + caret-color: transparent; + } + + // Resize cursor + &.resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } +} + +/* ===================== + TEXT DECORATION + ===================== */ +.tiptap.ProseMirror { + // Text decoration inheritance for spans + a span { + text-decoration: underline; + } + + s span { + text-decoration: line-through; + } + + u span { + text-decoration: underline; + } + + .tiptap-ai-insertion { + color: var(--tiptap-ai-insertion-color); + } +} + +/* ===================== + COLLABORATION + ===================== */ +.tiptap.ProseMirror { + .collaboration-carets { + &__caret { + border-right: 1px solid transparent; + border-left: 1px solid transparent; + pointer-events: none; + margin-left: -1px; + margin-right: -1px; + position: relative; + word-break: normal; + } + + &__label { + color: var(--tt-collaboration-carets-label); + border-radius: 0.25rem; + border-bottom-left-radius: 0; + font-size: 0.75rem; + font-weight: 600; + left: -1px; + line-height: 1; + padding: 0.125rem 0.375rem; + position: absolute; + top: -1.3em; + user-select: none; + white-space: nowrap; + } + } +} + +/* ===================== + EMOJI + ===================== */ +.tiptap.ProseMirror [data-type="emoji"] img { + display: inline-block; + width: 1.25em; + height: 1.25em; + cursor: text; +} + +/* ===================== + LINKS + ===================== */ +.tiptap.ProseMirror { + a { + color: var(--link-text-color); + text-decoration: underline; + } +} + +/* ===================== + MENTION + ===================== */ +.tiptap.ProseMirror { + [data-type="mention"] { + display: inline-block; + color: var(--tt-brand-color-500); + } +} + +/* ===================== + THREADS + ===================== */ +.tiptap.ProseMirror { + // Base styles for inline threads + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--inline { + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + color: var(--thread-text); + border-bottom: 2px dashed var(--tt-color-yellow-base); + font-weight: 600; + + &.tiptap-thread--selected, + &.tiptap-thread--hovered { + background-color: var(--thread-bg-color); + border-bottom-color: transparent; + } + } + + // Block thread styles with images + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--block { + &:has(img) { + outline: 0.125rem solid var(--tt-color-yellow-base); + border-radius: var(--tt-radius-xs, 0.25rem); + overflow: hidden; + width: fit-content; + + &.tiptap-thread--selected { + outline-width: 0.25rem; + outline-color: var(--tt-color-yellow-base); + } + + &.tiptap-thread--hovered { + outline-width: 0.25rem; + } + } + + // Block thread styles without images + &:not(:has(img)) { + border-radius: 0.25rem; + border-bottom: 0.125rem dashed var(--tt-color-yellow-base); + border-top: 0.125rem dashed var(--tt-color-yellow-base); + // padding-bottom: 0.5rem; + outline: 0.25rem solid transparent; + + &.tiptap-thread--hovered, + &.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + outline-color: var(--tt-color-yellow-base); + } + } + } + + // Resolved thread styles + .tiptap-thread.tiptap-thread--resolved.tiptap-thread--inline.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + border-color: transparent; + opacity: 0.5; + } + + // React renderer specific styles + .tiptap-thread.tiptap-thread--block:has(.react-renderer) { + margin-top: 3rem; + margin-bottom: 3rem; + } +} + +/* ===================== + PLACEHOLDER + ===================== */ +.is-empty:not(.with-slash)[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: attr(data-placeholder); +} + +.is-empty.with-slash[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: "Write, type '/' for commands…"; + font-style: italic; +} + +.is-empty[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + ):before { + pointer-events: none; + height: 0; + position: absolute; + width: 100%; + text-align: inherit; + left: 0; + right: 0; +} + +.is-empty[data-placeholder]:has(> .ProseMirror-trailingBreak):before { + color: var(--placeholder-color); +} + +/* ===================== + DROPCURSOR + ===================== */ +.prosemirror-dropcursor-block, +.prosemirror-dropcursor-inline { + background: var(--tt-brand-color-400) !important; + border-radius: 0.25rem; + margin-left: -1px; + margin-right: -1px; + width: 100%; + height: 0.188rem; + cursor: grabbing; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/helpers/create-image.ts b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/helpers/create-image.ts new file mode 100644 index 00000000..a9d25240 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/helpers/create-image.ts @@ -0,0 +1,286 @@ +import type { Editor } from '@tiptap/core'; + +const STYLE_PROPS: (keyof CSSStyleDeclaration | string)[] = [ + // Box & border + 'boxSizing', + 'backgroundColor', + 'borderTopColor', + 'borderRightColor', + 'borderBottomColor', + 'borderLeftColor', + 'borderTopStyle', + 'borderRightStyle', + 'borderBottomStyle', + 'borderLeftStyle', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderRadius', + // Spacing + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + // Typography + 'color', + 'font', + 'fontFamily', + 'fontSize', + 'fontWeight', + 'fontStyle', + 'lineHeight', + 'letterSpacing', + 'textTransform', + 'textDecoration', + 'textAlign', + 'verticalAlign', + 'whiteSpace', + // Sizing + 'width', + 'minWidth', + 'maxWidth', + 'height', + 'minHeight', + 'maxHeight', + // Table specifics + 'backgroundClip' +]; + +const toDash = (p: string) => p.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); + +/** + * Copy a curated list of computed styles from source -> target + * (Works for TD/TH and most inline content you'd expect inside.) + */ +function copyComputedStyles(source: HTMLElement, target: HTMLElement) { + const cs = getComputedStyle(source); + + for (const p of STYLE_PROPS) { + const prop = String(p); + const val = cs.getPropertyValue(toDash(prop)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (val) (target.style as any)[prop] = val; + } + + // Ensure long content doesn't overflow the drag image + target.style.overflow = 'hidden'; + target.style.textOverflow = 'ellipsis'; + // Respect existing wrapping if set on the source; otherwise prefer single line + if (cs.whiteSpace === '' || cs.whiteSpace === 'normal') { + target.style.whiteSpace = 'nowrap'; + } +} + +/** + * Deep clone a node and copy computed styles element-by-element. + * Avoids the browser's default cloning which loses computed styles. + */ +function cloneWithStyles(root: HTMLElement): HTMLElement { + const clone = root.cloneNode(true) as HTMLElement; + + // Iterative walk to avoid recursion limits + const q: Array<{ src: Element; dst: Element }> = [{ src: root, dst: clone }]; + while (q.length) { + const { src, dst } = q.shift()!; + if (src instanceof HTMLElement && dst instanceof HTMLElement) { + copyComputedStyles(src, dst); + } + const srcChildren = Array.from(src.children); + const dstChildren = Array.from(dst.children); + const len = Math.min(srcChildren.length, dstChildren.length); + for (let i = 0; i < len; i++) { + const srcChild = srcChildren[i]; + const dstChild = dstChildren[i]; + if (srcChild && dstChild) { + q.push({ src: srcChild, dst: dstChild }); + } + } + } + + return clone; +} + +/** + * Apply crisp, rounded, off-screen wrapper styling for drag image. + */ +function styleDragWrapper(el: HTMLElement, maxWidth: number) { + Object.assign(el.style, { + position: 'fixed', + top: '-10000px', + left: '-10000px', + pointerEvents: 'none', + zIndex: '2147483647', + maxWidth: `${maxWidth}px`, + borderRadius: '12px', + background: 'transparent', + filter: + 'drop-shadow(0 8px 24px rgba(0,0,0,0.18)) drop-shadow(0 2px 8px rgba(0,0,0,0.10))', + overflow: 'hidden' + } as CSSStyleDeclaration); +} + +/** + * Scale an element down if it exceeds the max width, keeping crisp layout. + * Assumes the element is already positioned off-screen (so attaching to body is safe). + */ +function scaleToFit(el: HTMLElement, maxWidth: number): void { + // Attach once (if not already) so measurements are correct. + if (!el.isConnected) document.body.appendChild(el); + const rect = el.getBoundingClientRect(); + if (rect.width > maxWidth && rect.width > 0) { + const scale = maxWidth / rect.width; + el.style.transformOrigin = 'top left'; + el.style.transform = `scale(${scale})`; + } +} + +/** + * Copy table-level styles that affect layout. + */ +function applyTableBoxStyles( + srcTable: HTMLTableElement, + dstTable: HTMLTableElement +) { + const tcs = getComputedStyle(srcTable); + dstTable.style.borderCollapse = tcs.borderCollapse; + dstTable.style.borderSpacing = tcs.borderSpacing; + dstTable.style.tableLayout = 'fixed'; // consistent drag image + dstTable.className = srcTable.className; +} + +/** + * Lock a cell's width to its rendered width. + */ +function lockCellWidth(fromCell: HTMLElement, toCell: HTMLElement) { + const rect = fromCell.getBoundingClientRect(); + if (rect.width > 0) { + toCell.style.width = `${rect.width}px`; + toCell.style.maxWidth = `${rect.width}px`; + } +} + +/** + * Build a 1-row preview table. + */ +function buildRowPreview( + tableEl: HTMLTableElement, + rowIndex: number +): HTMLTableElement | null { + const body = tableEl.tBodies?.[0] ?? tableEl.querySelector('tbody'); + if (!body) return null; + + const row = body.rows?.[rowIndex] as HTMLTableRowElement | undefined; + if (!row) return null; + + const tableClone = document.createElement('table'); + const tbodyClone = document.createElement('tbody'); + const rowClone = cloneWithStyles(row) as HTMLTableRowElement; + + applyTableBoxStyles(tableEl, tableClone); + + // Lock each cell width + for (let i = 0; i < row.cells.length; i++) { + const src = row.cells[i] as HTMLElement; + const dst = rowClone.cells[i] as HTMLElement | undefined; + if (dst) lockCellWidth(src, dst); + } + + tbodyClone.appendChild(rowClone); + tableClone.appendChild(tbodyClone); + return tableClone; +} + +/** + * Build a 1-column preview table (one cell per row). + */ +function buildColumnPreview( + tableEl: HTMLTableElement, + colIndex: number +): HTMLTableElement | null { + const body = tableEl.tBodies?.[0] ?? tableEl.querySelector('tbody'); + if (!body) return null; + + const tableClone = document.createElement('table'); + const tbodyClone = document.createElement('tbody'); + applyTableBoxStyles(tableEl, tableClone); + + let firstCellWidth = 0; + + for (let r = 0; r < body.rows.length; r++) { + const srcRow = body.rows[r]; + if (!srcRow) continue; + const srcCell = srcRow.cells?.[colIndex] as HTMLElement | undefined; + if (!srcCell) continue; + + const tr = document.createElement('tr'); + const cellClone = cloneWithStyles(srcCell); + + const rect = srcCell.getBoundingClientRect(); + if (!firstCellWidth && rect.width > 0) firstCellWidth = rect.width; + lockCellWidth(srcCell, cellClone); + + tr.appendChild(cellClone); + tbodyClone.appendChild(tr); + } + + if (firstCellWidth > 0) { + tableClone.style.width = `${firstCellWidth}px`; + tableClone.style.maxWidth = `${firstCellWidth}px`; + } + + tableClone.appendChild(tbodyClone); + return tableClone; +} + +/** + * Public API + * Creates a polished drag image for a row/column from a TipTap/ProseMirror table. + * - Subtle rounded corners & shadow + * - Scales down if it exceeds editor width + * - Preserves computed styles to look 1:1 with the table + */ +export function createTableDragImage( + editor: Editor, + orientation: 'row' | 'col', + index: number, + tablePos: number +): HTMLElement { + const editorRect = editor.view.dom.getBoundingClientRect(); + const maxWidth = Math.max(0, editorRect.width); + + const wrapper = document.createElement('div'); + styleDragWrapper(wrapper, maxWidth); + + const tableEl = editor.view.nodeDOM(tablePos) as HTMLTableElement | null; + if (!tableEl) { + document.body.appendChild(wrapper); + return wrapper; + } + + const tableRect = tableEl.getBoundingClientRect(); + const dragWidth = Math.min(tableRect.width, editorRect.width); + wrapper.style.width = `${dragWidth}px`; + + const preview = + orientation === 'row' + ? buildRowPreview(tableEl, index) + : buildColumnPreview(tableEl, index); + + if (preview) { + const card = document.createElement('div'); + Object.assign(card.style, { + background: 'var(--drag-image-bg, transparent)', + overflow: 'hidden' + } as CSSStyleDeclaration); + + card.appendChild(preview); + wrapper.appendChild(card); + } + + // Measure & scale after attaching + scaleToFit(wrapper, maxWidth); + + return wrapper; +} +// === END UTILS === diff --git a/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/index.ts b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/index.ts new file mode 100644 index 00000000..7331ff44 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/index.ts @@ -0,0 +1,2 @@ +export * from './table-handle'; +export * from './table-handle-plugin'; diff --git a/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/table-handle-plugin.ts b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/table-handle-plugin.ts new file mode 100644 index 00000000..a48136bc --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/table-handle-plugin.ts @@ -0,0 +1,853 @@ +import type { Editor } from '@tiptap/core'; +import type { Node as TiptapNode } from '@tiptap/pm/model'; +import type { PluginView, Transaction } from '@tiptap/pm/state'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; +import { + CellSelection, + moveTableColumn, + moveTableRow, + TableMap +} from '@tiptap/pm/tables'; +import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'; + +import { createTableDragImage } from '@workspace/editor/components/tiptap-node/table-node/extensions/table-handle/helpers/create-image'; +import { + clamp, + domCellAround, + getCellIndicesFromDOM, + getColumnCells, + getIndexCoordinates, + getRowCells, + getTableFromDOM, + isHTMLElement, + isTableNode, + safeClosest, + selectCellsByCoords +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { isValidPosition } from '@workspace/editor/lib/tiptap-utils'; + +export type TableHandlesState = { + show: boolean; + showAddOrRemoveRowsButton: boolean; + showAddOrRemoveColumnsButton: boolean; + referencePosCell?: DOMRect; + referencePosTable: DOMRect; + block: TiptapNode; + blockPos: number; + colIndex: number | undefined; + rowIndex: number | undefined; + draggingState?: + | { + draggedCellOrientation: 'row' | 'col'; + originalIndex: number; + mousePos: number; + initialOffset: number; + } + | undefined; + widgetContainer: HTMLElement | undefined; +}; + +function hideElements(selector: string, rootEl: Document | ShadowRoot) { + rootEl.querySelectorAll(selector).forEach((el) => { + el.style.visibility = 'hidden'; + }); +} + +export const tableHandlePluginKey = new PluginKey('tableHandlePlugin'); + +class TableHandleView implements PluginView { + public editor: Editor; + public editorView: EditorView; + + public state: TableHandlesState | undefined = undefined; + public menuFrozen = false; + public mouseState: 'up' | 'down' | 'selecting' = 'up'; + public tableId: string | undefined; + public tablePos: number | undefined; + public tableElement: HTMLElement | undefined; + + public emitUpdate: () => void; + + constructor( + editor: Editor, + editorView: EditorView, + emitUpdate: (state: TableHandlesState) => void + ) { + this.editor = editor; + this.editorView = editorView; + this.emitUpdate = () => this.state && emitUpdate(this.state); + + this.editorView.dom.addEventListener('mousemove', this.mouseMoveHandler); + this.editorView.dom.addEventListener( + 'mousedown', + this.viewMousedownHandler + ); + window.addEventListener('mouseup', this.mouseUpHandler); + + this.editorView.root.addEventListener( + 'dragover', + this.dragOverHandler as EventListener + ); + this.editorView.root.addEventListener( + 'drop', + this.dropHandler as unknown as EventListener + ); + } + + private viewMousedownHandler = (event: MouseEvent) => { + this.mouseState = 'down'; + + const { state, view } = this.editor; + if (!(state.selection instanceof CellSelection) || this.editor.isFocused) + return; + + const posInfo = view.posAtCoords({ + left: event.clientX, + top: event.clientY + }); + if (!posInfo) return; + + const $pos = state.doc.resolve(posInfo.pos); + const { nodes } = state.schema; + let paraDepth = -1; + let inTableCell = false; + + for (let d = $pos.depth; d >= 0; d--) { + const node = $pos.node(d); + if ( + !inTableCell && + (node.type === nodes.tableCell || node.type === nodes.tableHeader) + ) { + inTableCell = true; + } + if (paraDepth === -1 && node.type === nodes.paragraph) { + paraDepth = d; + } + if (inTableCell && paraDepth !== -1) break; + } + + if (!inTableCell || paraDepth === -1) return; + + const from = $pos.start(paraDepth); + const to = $pos.end(paraDepth); + const nextSel = TextSelection.create(state.doc, from, to); + if (state.selection.eq(nextSel)) return; + + view.dispatch(state.tr.setSelection(nextSel)); + view.focus(); + }; + + private mouseUpHandler = (event: MouseEvent) => { + this.mouseState = 'up'; + this.mouseMoveHandler(event); + }; + + private mouseMoveHandler = (event: MouseEvent) => { + if (this.menuFrozen || this.mouseState === 'selecting') return; + + const target = event.target; + if (!isHTMLElement(target) || !this.editorView.dom.contains(target)) return; + + this._handleMouseMoveNow(event); + }; + + private hideHandles() { + if (!this.state?.show) return; + + this.state = { + ...this.state, + show: false, + showAddOrRemoveRowsButton: false, + showAddOrRemoveColumnsButton: false, + colIndex: undefined, + rowIndex: undefined, + referencePosCell: undefined + }; + this.emitUpdate(); + } + + private _handleMouseMoveNow(event: MouseEvent) { + const around = domCellAround(event.target as Element); + + // Hide handles while selecting inside a cell + if ( + around?.type === 'cell' && + this.mouseState === 'down' && + !this.state?.draggingState + ) { + this.mouseState = 'selecting'; + this.hideHandles(); + return; + } + + if (!around || !this.editor.isEditable) { + this.hideHandles(); + return; + } + + const tbody = around.tbodyNode; + if (!tbody) return; + + const tableRect = tbody.getBoundingClientRect(); + const coords = this.editor.view.posAtCoords({ + left: event.clientX, + top: event.clientY + }); + if (!coords) return; + + // Find the table node at this position + const $pos = this.editor.view.state.doc.resolve(coords.pos); + let blockInfo: { node: TiptapNode; pos: number } | undefined; + for (let d = $pos.depth; d >= 0; d--) { + const node = $pos.node(d); + if (isTableNode(node)) { + blockInfo = { node, pos: d === 0 ? 0 : $pos.before(d) }; + break; + } + } + if (!blockInfo || blockInfo.node.type.name !== 'table') return; + + this.tableElement = this.editor.view.nodeDOM(blockInfo.pos) as + | HTMLElement + | undefined; + this.tablePos = blockInfo.pos; + this.tableId = blockInfo.node.attrs.id; + + const wrapper = safeClosest(around.domNode, '.tableWrapper'); + const widgetContainer = wrapper?.querySelector('.table-controls') as + | HTMLElement + | undefined; + + // Hovering around the table (outside cells) + if (around.type === 'wrapper') { + const below = + event.clientY >= tableRect.bottom - 1 && + event.clientY < tableRect.bottom + 20; + const right = + event.clientX >= tableRect.right - 1 && + event.clientX < tableRect.right + 20; + const cursorBeyondRightOrBottom = + event.clientX > tableRect.right || event.clientY > tableRect.bottom; + + this.state = { + ...this.state, + show: true, + showAddOrRemoveRowsButton: below, + showAddOrRemoveColumnsButton: right, + referencePosTable: tableRect, + block: blockInfo.node, + blockPos: blockInfo.pos, + widgetContainer, + colIndex: cursorBeyondRightOrBottom ? undefined : this.state?.colIndex, + rowIndex: cursorBeyondRightOrBottom ? undefined : this.state?.rowIndex, + referencePosCell: cursorBeyondRightOrBottom + ? undefined + : this.state?.referencePosCell + }; + } else { + // Hovering over a cell + const cellPosition = getCellIndicesFromDOM( + around.domNode as HTMLTableCellElement, + blockInfo.node, + this.editor + ); + if (!cellPosition) return; + + const { rowIndex, colIndex } = cellPosition; + const cellRect = (around.domNode as HTMLElement).getBoundingClientRect(); + const lastRowIndex = blockInfo.node.content.childCount - 1; + const lastColIndex = + (blockInfo.node.content.firstChild?.content.childCount ?? 0) - 1; + + // Skip update if same cell + if ( + this.state?.show && + this.tableId === blockInfo.node.attrs.id && + this.state.rowIndex === rowIndex && + this.state.colIndex === colIndex + ) { + return; + } + + this.state = { + show: true, + showAddOrRemoveColumnsButton: colIndex === lastColIndex, + showAddOrRemoveRowsButton: rowIndex === lastRowIndex, + referencePosTable: tableRect, + block: blockInfo.node, + blockPos: blockInfo.pos, + draggingState: undefined, + referencePosCell: cellRect, + colIndex, + rowIndex, + widgetContainer + }; + } + + this.emitUpdate(); + return false; + } + + dragOverHandler = (event: DragEvent) => { + if (this.state?.draggingState === undefined) { + return; + } + + event.preventDefault(); + event.dataTransfer!.dropEffect = 'move'; + + hideElements( + '.prosemirror-dropcursor-block, .prosemirror-dropcursor-inline', + this.editorView.root + ); + + // The mouse cursor coordinates, bounded to the table's bounding box. + const { + left: tableLeft, + right: tableRight, + top: tableTop, + bottom: tableBottom + } = this.state.referencePosTable; + + const boundedMouseCoords = { + left: clamp(event.clientX, tableLeft + 1, tableRight - 1), + top: clamp(event.clientY, tableTop + 1, tableBottom - 1) + }; + + // Gets the table cell element + const tableCellElements = this.editorView.root + .elementsFromPoint(boundedMouseCoords.left, boundedMouseCoords.top) + .filter( + (element) => element.tagName === 'TD' || element.tagName === 'TH' + ); + if (tableCellElements.length === 0) { + return; + } + const tableCellElement = tableCellElements[0]; + if (!isHTMLElement(tableCellElement)) { + return; + } + + const cellPosition = getCellIndicesFromDOM( + tableCellElement as HTMLTableCellElement, + this.state.block, + this.editor + ); + if (!cellPosition) return; + + const { rowIndex, colIndex } = cellPosition; + + // Check what changed + const oldIndex = + this.state.draggingState.draggedCellOrientation === 'row' + ? this.state.rowIndex + : this.state.colIndex; + const newIndex = + this.state.draggingState.draggedCellOrientation === 'row' + ? rowIndex + : colIndex; + const dispatchDecorationsTransaction = newIndex !== oldIndex; + + const mousePos = + this.state.draggingState.draggedCellOrientation === 'row' + ? boundedMouseCoords.top + : boundedMouseCoords.left; + + // Check if anything needs updating + const cellChanged = + this.state.rowIndex !== rowIndex || this.state.colIndex !== colIndex; + const mousePosChanged = this.state.draggingState.mousePos !== mousePos; + + if (cellChanged || mousePosChanged) { + this.state = { + ...this.state, + rowIndex: rowIndex, + colIndex: colIndex, + referencePosCell: tableCellElement.getBoundingClientRect(), + draggingState: { + ...this.state.draggingState, + mousePos: mousePos + } + }; + + this.emitUpdate(); + } + + // Dispatch decorations transaction if needed + if (dispatchDecorationsTransaction) { + this.editor.view.dispatch( + this.editor.state.tr.setMeta(tableHandlePluginKey, true) + ); + } + }; + + dropHandler = () => { + this.mouseState = 'up'; + + const st = this.state; + if (!st?.draggingState) return false; + + const { draggingState, rowIndex, colIndex, blockPos } = st; + if (!isValidPosition(blockPos)) return false; + + if ( + (draggingState.draggedCellOrientation === 'row' && + rowIndex === undefined) || + (draggingState.draggedCellOrientation === 'col' && colIndex === undefined) + ) { + throw new Error( + 'Attempted to drop table row or column, but no table block was hovered prior.' + ); + } + + const isRow = draggingState.draggedCellOrientation === 'row'; + const orientation = isRow ? 'row' : 'column'; + const destIndex = isRow ? rowIndex! : colIndex!; + + const cellCoords = getIndexCoordinates({ + editor: this.editor, + index: draggingState.originalIndex, + orientation, + tablePos: blockPos + }); + if (!cellCoords) return false; + + const stateWithCellSel = selectCellsByCoords( + this.editor, + blockPos, + cellCoords, + { mode: 'state' } + ); + if (!stateWithCellSel) return false; + + const dispatch = (tr: Transaction) => this.editor.view.dispatch(tr); + + if (isRow) { + moveTableRow({ + from: draggingState.originalIndex, + to: destIndex, + select: true, + pos: blockPos + 1 + })(stateWithCellSel, dispatch); + } else { + moveTableColumn({ + from: draggingState.originalIndex, + to: destIndex, + select: true, + pos: blockPos + 1 + })(stateWithCellSel, dispatch); + } + + this.state = { ...st, draggingState: undefined }; + this.emitUpdate(); + + this.editor.view.dispatch( + this.editor.state.tr.setMeta(tableHandlePluginKey, null) + ); + + return true; + }; + + update(view: EditorView): void { + const pluginState = tableHandlePluginKey.getState(view.state); + if (pluginState !== undefined && pluginState !== this.menuFrozen) { + this.menuFrozen = pluginState; + } + + if (!this.state?.show) return; + + if (!this.tableElement?.isConnected) { + this.hideHandles(); + return; + } + + const tableInfo = getTableFromDOM(this.tableElement, this.editor); + if (!tableInfo) { + this.hideHandles(); + return; + } + + // Check if table changed + const blockChanged = + this.state.block !== tableInfo.node || + this.state.blockPos !== tableInfo.pos; + + if ( + !tableInfo.node || + tableInfo.node.type.name !== 'table' || + !this.tableElement?.isConnected + ) { + this.hideHandles(); + return; + } + + const { height: rowCount, width: colCount } = TableMap.get(tableInfo.node); + + // Calculate new indices + let newRowIndex = this.state.rowIndex; + let newColIndex = this.state.colIndex; + + // Clamp indices if rows/columns were deleted + if (newRowIndex !== undefined && newRowIndex >= rowCount) { + newRowIndex = rowCount ? rowCount - 1 : undefined; + } + if (newColIndex !== undefined && newColIndex >= colCount) { + newColIndex = colCount ? colCount - 1 : undefined; + } + + const tableBody = this.tableElement.querySelector('tbody'); + if (!tableBody) { + throw new Error( + "Table block does not contain a 'tbody' HTML element. This should never happen." + ); + } + + // Calculate new reference positions + let newReferencePosCell = this.state.referencePosCell; + if (newRowIndex !== undefined && newColIndex !== undefined) { + const rowEl = tableBody.children[newRowIndex]; + const cellEl = rowEl?.children[newColIndex]; + + if (cellEl) { + newReferencePosCell = cellEl.getBoundingClientRect(); + } else { + newRowIndex = undefined; + newColIndex = undefined; + newReferencePosCell = undefined; + } + } + + const newReferencePosTable = tableBody.getBoundingClientRect(); + + // Check if anything changed + const indicesChanged = + newRowIndex !== this.state.rowIndex || + newColIndex !== this.state.colIndex; + const refPosChanged = + newReferencePosCell !== this.state.referencePosCell || + newReferencePosTable !== this.state.referencePosTable; + + if (blockChanged || indicesChanged || refPosChanged) { + this.state = { + ...this.state, + block: tableInfo.node, + blockPos: tableInfo.pos, + rowIndex: newRowIndex, + colIndex: newColIndex, + referencePosCell: newReferencePosCell, + referencePosTable: newReferencePosTable + }; + this.emitUpdate(); + } + } + + destroy(): void { + this.editorView.dom.removeEventListener( + 'mousemove', + this.mouseMoveHandler as EventListener + ); + window.removeEventListener('mouseup', this.mouseUpHandler as EventListener); + this.editorView.dom.removeEventListener( + 'mousedown', + this.viewMousedownHandler as EventListener + ); + this.editorView.root.removeEventListener( + 'dragover', + this.dragOverHandler as EventListener + ); + this.editorView.root.removeEventListener( + 'drop', + this.dropHandler as unknown as EventListener + ); + } +} + +let tableHandleView: TableHandleView | null = null; + +export function TableHandlePlugin( + editor: Editor, + emitUpdate: (state: TableHandlesState) => void +): Plugin { + return new Plugin({ + key: tableHandlePluginKey, + + state: { + init: () => false, + apply: (tr, frozen) => { + const meta = tr.getMeta(tableHandlePluginKey); + return meta !== undefined ? meta : frozen; + } + }, + + view: (editorView) => { + tableHandleView = new TableHandleView(editor, editorView, emitUpdate); + + return tableHandleView; + }, + + props: { + decorations: (state) => { + if (!tableHandleView) return null; + + if ( + tableHandleView === undefined || + tableHandleView.state === undefined || + tableHandleView.state.draggingState === undefined || + tableHandleView.tablePos === undefined + ) { + return; + } + + const newIndex = + tableHandleView.state.draggingState.draggedCellOrientation === 'row' + ? tableHandleView.state.rowIndex + : tableHandleView.state.colIndex; + + if (newIndex === undefined) { + return; + } + + const decorations: Decoration[] = []; + const { draggingState } = tableHandleView.state; + const { originalIndex } = draggingState; + + if ( + tableHandleView.state.draggingState.draggedCellOrientation === 'row' + ) { + const originalCells = getRowCells( + editor, + originalIndex, + tableHandleView.state.blockPos + ); + originalCells.cells.forEach((cell) => { + if (cell.node) { + decorations.push( + Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { + class: 'table-cell-dragging-source' + }) + ); + } + }); + } else { + const originalCells = getColumnCells( + editor, + originalIndex, + tableHandleView.state.blockPos + ); + originalCells.cells.forEach((cell) => { + if (cell.node) { + decorations.push( + Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { + class: 'table-cell-dragging-source' + }) + ); + } + }); + } + + // Return empty decorations if: + // - original index is same as new index (no change) + // - editor is not defined for some reason + if (newIndex === originalIndex || !editor) { + return DecorationSet.create(state.doc, decorations); + } + + if ( + tableHandleView.state.draggingState.draggedCellOrientation === 'row' + ) { + const cellsInRow = getRowCells( + editor, + newIndex, + tableHandleView.state.blockPos + ); + + cellsInRow.cells.forEach((cell) => { + const cellNode = cell.node; + if (!cellNode) { + return; + } + + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cell.pos + (newIndex > originalIndex ? cellNode.nodeSize - 2 : 2); + decorations.push( + Decoration.widget(decorationPos, () => { + const widget = document.createElement('div'); + widget.className = 'tiptap-table-dropcursor'; + widget.style.left = '0'; + widget.style.right = '0'; + // This is only necessary because the drop indicator's height + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the row is being dropped. + if (newIndex > originalIndex) { + widget.style.bottom = '-1px'; + } else { + widget.style.top = '-1px'; + } + widget.style.height = '3px'; + + return widget; + }) + ); + }); + } else { + const cellsInColumn = getColumnCells( + editor, + newIndex, + tableHandleView.state.blockPos + ); + cellsInColumn.cells.forEach((cell) => { + const cellNode = cell.node; + if (!cellNode) { + return; + } + // Creates a decoration at the start or end of each cell, + // depending on whether the new index is before or after the + // original index. + const decorationPos = + cell.pos + (newIndex > originalIndex ? cellNode.nodeSize - 2 : 2); + decorations.push( + Decoration.widget(decorationPos, () => { + const widget = document.createElement('div'); + widget.className = 'tiptap-table-dropcursor'; + widget.style.top = '0'; + widget.style.bottom = '0'; + // This is only necessary because the drop indicator's width + // is an even number of pixels, whereas the border between + // table cells is an odd number of pixels. So this makes the + // positioning slightly more consistent regardless of where + // the column is being dropped. + if (newIndex > originalIndex) { + widget.style.right = '-1px'; + } else { + widget.style.left = '-1px'; + } + widget.style.width = '3px'; + return widget; + }) + ); + }); + } + + return DecorationSet.create(state.doc, decorations); + } + } + }); +} + +/** + * Shared drag start handler for table rows and columns + */ +const tableDragStart = ( + orientation: 'col' | 'row', + event: { + dataTransfer: DataTransfer | null; + currentTarget: EventTarget & Element; + clientX: number; + clientY: number; + } +) => { + if (!tableHandleView?.state) { + throw new Error( + `Attempted to drag table ${orientation}, but no table block was hovered prior.` + ); + } + + const { state, editor } = tableHandleView; + const index = orientation === 'col' ? state.colIndex : state.rowIndex; + + if (index === undefined) { + throw new Error( + `Attempted to drag table ${orientation}, but no table block was hovered prior.` + ); + } + + const { blockPos, referencePosCell } = state; + const mousePos = orientation === 'col' ? event.clientX : event.clientY; + + // Clear cell selection to prevent table reference collapse + if (editor.state.selection instanceof CellSelection) { + const safeSel = TextSelection.near(editor.state.doc.resolve(blockPos), 1); + editor.view.dispatch(editor.state.tr.setSelection(safeSel)); + } + + const dragImage = createTableDragImage(editor, orientation, index, blockPos); + + // Configure drag image + if (event.dataTransfer) { + const handleRect = ( + event.currentTarget as HTMLElement + ).getBoundingClientRect(); + const offset = + orientation === 'col' + ? { x: handleRect.width / 2, y: 0 } + : { x: 0, y: handleRect.height / 2 }; + + event.dataTransfer.effectAllowed = + orientation === 'col' ? 'move' : 'copyMove'; + event.dataTransfer.setDragImage(dragImage, offset.x, offset.y); + } + + // Cleanup drag image + const cleanup = () => dragImage.parentNode?.removeChild(dragImage); + document.addEventListener('drop', cleanup, { once: true }); + document.addEventListener('dragend', cleanup, { once: true }); + + const initialOffset = referencePosCell + ? (orientation === 'col' ? referencePosCell.left : referencePosCell.top) - + mousePos + : 0; + + // Update dragging state + tableHandleView.state = { + ...state, + draggingState: { + draggedCellOrientation: orientation, + originalIndex: index, + mousePos, + initialOffset + } + }; + tableHandleView.emitUpdate(); + editor.view.dispatch(editor.state.tr.setMeta(tableHandlePluginKey, true)); +}; + +/** + * Callback for column drag handle + */ +export const colDragStart = (event: { + dataTransfer: DataTransfer | null; + currentTarget: EventTarget & Element; + clientX: number; +}) => tableDragStart('col', { ...event, clientY: 0 }); + +/** + * Callback for row drag handle + */ +export const rowDragStart = (event: { + dataTransfer: DataTransfer | null; + currentTarget: EventTarget & Element; + clientY: number; +}) => tableDragStart('row', { ...event, clientX: 0 }); + +/** + * Drag end cleanup + */ +export const dragEnd = () => { + if (!tableHandleView || tableHandleView.state === undefined) { + return; + } + + tableHandleView.state = { + ...tableHandleView.state, + draggingState: undefined + }; + tableHandleView.emitUpdate(); + + const editor = tableHandleView.editor; + editor.view.dispatch(editor.state.tr.setMeta(tableHandlePluginKey, null)); +}; diff --git a/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/table-handle.ts b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/table-handle.ts new file mode 100644 index 00000000..2be3e43c --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/extensions/table-handle/table-handle.ts @@ -0,0 +1,51 @@ +import { Extension } from '@tiptap/core'; + +import type { TableHandlesState } from '@workspace/editor/components/tiptap-node/table-node/extensions/table-handle'; +import { + TableHandlePlugin, + tableHandlePluginKey +} from '@workspace/editor/components/tiptap-node/table-node/extensions/table-handle/table-handle-plugin'; + +declare module '@tiptap/core' { + interface Commands { + tableHandle: { + freezeHandles: () => ReturnType; + unfreezeHandles: () => ReturnType; + }; + } + + interface EditorEvents { + tableHandleState: TableHandlesState; + } +} + +export const TableHandleExtension = Extension.create({ + name: 'tableHandleExtension', + + addCommands() { + return { + freezeHandles: + () => + ({ tr, dispatch }) => { + if (dispatch) tr.setMeta(tableHandlePluginKey, true); + return true; + }, + + unfreezeHandles: + () => + ({ tr, dispatch }) => { + if (dispatch) tr.setMeta(tableHandlePluginKey, false); + return true; + } + }; + }, + + addProseMirrorPlugins() { + const { editor } = this; + return [ + TableHandlePlugin(editor, (state) => { + this.editor.emit('tableHandleState', state); + }) + ]; + } +}); diff --git a/packages/editor/src/components/tiptap-node/table-node/extensions/table-node-extension.ts b/packages/editor/src/components/tiptap-node/table-node/extensions/table-node-extension.ts new file mode 100644 index 00000000..03561de6 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/extensions/table-node-extension.ts @@ -0,0 +1,247 @@ +import { Extension } from '@tiptap/core'; +import type { + TableCellOptions, + TableHeaderOptions, + TableOptions, + TableRowOptions +} from '@tiptap/extension-table'; +import { TableCell, TableHeader, TableRow } from '@tiptap/extension-table'; +import { Table } from '@tiptap/extension-table/table'; +import type { Node } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { + cellAround, + columnResizing, + tableEditing, + TableView +} from '@tiptap/pm/tables'; +import type { ViewMutationRecord } from '@tiptap/pm/view'; + +import { + EMPTY_CELL_WIDTH, + RESIZE_MIN_WIDTH +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; + +export const TableNode = Table.extend({ + addProseMirrorPlugins() { + const isResizable = this.options.resizable && this.editor.isEditable; + + const defaultCellMinWidth = + this.options.cellMinWidth < EMPTY_CELL_WIDTH + ? EMPTY_CELL_WIDTH + : this.options.cellMinWidth; + + return [ + ...(isResizable + ? [ + columnResizing({ + handleWidth: this.options.handleWidth, + cellMinWidth: RESIZE_MIN_WIDTH, + defaultCellMinWidth, + View: null, + lastColumnResizable: this.options.lastColumnResizable + }) + ] + : []), + tableEditing({ + allowTableNodeSelection: this.options.allowTableNodeSelection + }) + ]; + }, + + addNodeView() { + return ({ node, HTMLAttributes }) => { + class TiptapTableView extends TableView { + private readonly blockContainer: HTMLDivElement; + private readonly innerTableContainer: HTMLDivElement; + private readonly widgetsContainer: HTMLDivElement; + private readonly overlayContainer: HTMLDivElement; + + declare readonly node: Node; + declare readonly minCellWidth: number; + private readonly containerAttributes: Record; + + constructor( + node: Node, + minCellWidth: number, + containerAttributes: Record + ) { + super(node, minCellWidth); + + this.containerAttributes = containerAttributes ?? {}; + + this.blockContainer = this.createBlockContainer(); + this.innerTableContainer = this.createInnerTableContainer(); + this.widgetsContainer = this.createWidgetsContainer(); + this.overlayContainer = this.createOverlayContainer(); + + this.setupDOMStructure(); + } + + private createBlockContainer(): HTMLDivElement { + const container = document.createElement('div'); + container.setAttribute('data-content-type', 'table'); + + this.applyContainerAttributes(container); + return container; + } + + private createInnerTableContainer(): HTMLDivElement { + const container = document.createElement('div'); + container.className = 'table-container'; + return container; + } + + private createWidgetsContainer(): HTMLDivElement { + const container = document.createElement('div'); + container.className = 'table-controls'; + container.style.position = 'relative'; + return container; + } + + private createOverlayContainer(): HTMLDivElement { + const container = document.createElement('div'); + container.className = 'table-selection-overlay-container'; + return container; + } + + private applyContainerAttributes(element: HTMLDivElement): void { + Object.entries(this.containerAttributes).forEach(([key, value]) => { + if (key !== 'class') { + element.setAttribute(key, value); + } + }); + } + + private setupDOMStructure(): void { + const originalTable = this.dom; + const tableElement = originalTable.firstChild!; + + // Move table into inner container + this.innerTableContainer.appendChild(tableElement); + + // Build the hierarchy: blockContainer > originalTable > innerContainer + widgetsContainer + originalTable.appendChild(this.innerTableContainer); + originalTable.appendChild(this.widgetsContainer); + originalTable.appendChild(this.overlayContainer); + + this.blockContainer.appendChild(originalTable); + + this.dom = this.blockContainer; + } + + ignoreMutation(mutation: ViewMutationRecord): boolean { + const target = mutation.target as HTMLElement; + const isInsideTable = target.closest('.table-container'); + + return !isInsideTable || super.ignoreMutation(mutation); + } + } + + const cellMinWidth = + this.options.cellMinWidth < EMPTY_CELL_WIDTH + ? EMPTY_CELL_WIDTH + : this.options.cellMinWidth; + return new TiptapTableView(node, cellMinWidth, HTMLAttributes); + }; + } +}); + +const TableCellNode = TableCell.extend({ + addKeyboardShortcuts() { + return { + ...this.parent?.(), + 'Mod-a': () => { + const { state, view } = this.editor; + const { selection, doc } = state; + + const $anchor = selection.$anchor; + const cellPos = cellAround($anchor); + if (!cellPos) { + return false; + } + + const cellNode = doc.nodeAt(cellPos.pos); + if (!cellNode || !cellNode.textContent) { + return false; + } + + const from = cellPos.pos + 1; + const to = cellPos.pos + cellNode.nodeSize - 1; + + if (from >= to) { + return true; + } + + const $from = doc.resolve(from); + const $to = doc.resolve(to); + + const nextSel = TextSelection.between($from, $to, 1); + if (!nextSel) { + return true; + } + + if (state.selection.eq(nextSel)) { + return true; + } + + view.dispatch(state.tr.setSelection(nextSel)); + return true; + } + }; + } +}); + +export interface TableNodeOptions { + /** + * If set to false, the table extension will not be registered + * @example table: false + */ + table: Partial | false; + /** + * If set to false, the table extension will not be registered + * @example tableCell: false + */ + tableCell: Partial | false; + /** + * If set to false, the table extension will not be registered + * @example tableHeader: false + */ + tableHeader: Partial | false; + /** + * If set to false, the table extension will not be registered + * @example tableRow: false + */ + tableRow: Partial | false; +} + +/** + * The table kit is a collection of table editor extensions. + * + * It’s a good starting point for building your own table in Tiptap. + */ +export const TableKit = Extension.create({ + name: 'tableKit', + + addExtensions() { + const extensions = []; + + if (this.options.table !== false) { + extensions.push(TableNode.configure(this.options.table)); + } + + if (this.options.tableCell !== false) { + extensions.push(TableCellNode.configure(this.options.tableCell)); + } + + if (this.options.tableHeader !== false) { + extensions.push(TableHeader.configure(this.options.tableHeader)); + } + + if (this.options.tableRow !== false) { + extensions.push(TableRow.configure(this.options.tableRow)); + } + + return extensions; + } +}); diff --git a/packages/editor/src/components/tiptap-node/table-node/hooks/use-table-handle-state.ts b/packages/editor/src/components/tiptap-node/table-node/hooks/use-table-handle-state.ts new file mode 100644 index 00000000..94ef99ce --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/hooks/use-table-handle-state.ts @@ -0,0 +1,73 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Editor } from '@tiptap/react'; + +import type { TableHandlesState } from '@workspace/editor/components/tiptap-node/table-node/extensions/table-handle'; +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface UseTableHandleStateConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * Initial state of the table handles + */ + initialState?: TableHandlesState | null; + /** + * Only update state when specific fields change + */ + watchFields?: (keyof TableHandlesState)[]; + /** + * Callback when state changes + */ + onStateChange?: (state: TableHandlesState | null) => void; +} + +export function useTableHandleState(config: UseTableHandleStateConfig = {}) { + const { + editor: providedEditor, + initialState = null, + watchFields, + onStateChange + } = config; + + const { editor } = useTiptapEditor(providedEditor); + const [state, setState] = useState(initialState); + const prevStateRef = useRef(initialState); + + const updateState = useCallback( + (newState: TableHandlesState) => { + if (watchFields && prevStateRef.current) { + const shouldUpdate = watchFields.some( + (field) => prevStateRef.current![field] !== newState[field] + ); + if (!shouldUpdate) return; + } + + setState(newState); + prevStateRef.current = newState; + onStateChange?.(newState); + }, + [watchFields, onStateChange] + ); + + useEffect(() => { + if (!editor) { + setState(null); + prevStateRef.current = null; + onStateChange?.(null); + return; + } + + editor.on('tableHandleState', updateState); + + return () => { + editor.off('tableHandleState', updateState); + }; + }, [editor, onStateChange, updateState]); + + return state; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/lib/tiptap-table-utils.ts b/packages/editor/src/components/tiptap-node/table-node/lib/tiptap-table-utils.ts new file mode 100644 index 00000000..ead77b9b --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/lib/tiptap-table-utils.ts @@ -0,0 +1,1293 @@ +import type { Node } from '@tiptap/pm/model'; +import type { Command } from '@tiptap/pm/state'; +import { + Selection, + type EditorState, + type Transaction +} from '@tiptap/pm/state'; +import type { FindNodeResult, Rect } from '@tiptap/pm/tables'; +import { + cellAround, + CellSelection, + findTable, + isInTable, + selectedRect, + selectionCell, + TableMap +} from '@tiptap/pm/tables'; +import { Mapping } from '@tiptap/pm/transform'; +import type { Editor } from '@tiptap/react'; + +export const RESIZE_MIN_WIDTH = 35; +export const EMPTY_CELL_WIDTH = 120; +export const EMPTY_CELL_HEIGHT = 40; + +export type Orientation = 'row' | 'column'; +export interface CellInfo extends FindNodeResult { + row: number; + column: number; +} + +export type CellCoordinates = { + row: number; + col: number; +}; + +export type SelectionReturnMode = 'state' | 'transaction' | 'dispatch'; + +export type BaseSelectionOptions = { mode?: SelectionReturnMode }; +export type DispatchSelectionOptions = { + mode: 'dispatch'; + dispatch: (tr: Transaction) => void; +}; +export type TransactionSelectionOptions = { mode: 'transaction' }; +export type StateSelectionOptions = { mode?: 'state' }; + +export type TableInfo = { + map: TableMap; +} & FindNodeResult; + +// ============================================================================ +// HELPER CONSTANTS & UTILITIES +// ============================================================================ + +const EMPTY_CELLS_RESULT = { cells: [], mergedCells: [] }; + +export function isHTMLElement(n: unknown): n is HTMLElement { + return n instanceof HTMLElement; +} + +export type DomCellAroundResult = + | { + type: 'cell'; + domNode: HTMLElement; + tbodyNode: HTMLTableSectionElement | null; + } + | { + type: 'wrapper'; + domNode: HTMLElement; + tbodyNode: HTMLTableSectionElement | null; + }; + +export function safeClosest( + start: Element | null, + selector: string +): T | null { + return (start?.closest?.(selector) as T | null) ?? null; +} + +/** + * Walk up from an element until we find a TD/TH or the table wrapper. + * Returns the found element plus its tbody (if present). + */ +export function domCellAround( + target: Element +): DomCellAroundResult | undefined { + let current: Element | null = target; + + while ( + current && + current.tagName !== 'TD' && + current.tagName !== 'TH' && + !current.classList.contains('tableWrapper') + ) { + if (current.classList.contains('ProseMirror')) return undefined; + current = isHTMLElement(current.parentNode) + ? (current.parentNode as Element) + : null; + } + + if (!current) return undefined; + + if (current.tagName === 'TD' || current.tagName === 'TH') { + return { + type: 'cell', + domNode: current as HTMLElement, + tbodyNode: safeClosest(current, 'tbody') + }; + } + + return { + type: 'wrapper', + domNode: current as HTMLElement, + tbodyNode: (current as HTMLElement).querySelector('tbody') + }; +} + +/** + * Clamps a value between min and max bounds + */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} + +/** + * Validates if row/col indices are within table bounds + */ +function isWithinBounds(row: number, col: number, map: TableMap): boolean { + return row >= 0 && row < map.height && col >= 0 && col < map.width; +} + +/** + * Resolves the index for a row or column based on current selection or provided value + */ +function resolveOrientationIndex( + state: EditorState, + table: TableInfo, + orientation: Orientation, + providedIndex?: number +): number | null { + if (typeof providedIndex === 'number') { + return providedIndex; + } + + if (state.selection instanceof CellSelection) { + const rect = selectedRect(state); + return orientation === 'row' ? rect.top : rect.left; + } + + const $cell = cellAround(state.selection.$anchor) ?? selectionCell(state); + if (!$cell) return null; + + const rel = $cell.pos - table.start; + const rect = table.map.findCell(rel); + return orientation === 'row' ? rect.top : rect.left; +} + +/** + * Creates a CellInfo object from position data + */ +function createCellInfo( + row: number, + column: number, + cellPos: number, + cellNode: Node +): CellInfo { + return { + row, + column, + pos: cellPos, + node: cellNode, + start: cellPos + 1, + depth: cellNode ? cellNode.content.size : 0 + }; +} + +/** + * Checks if a cell is merged (has colspan or rowspan > 1) + */ +export function isCellMerged(node: Node | null): boolean { + if (!node) return false; + const colspan = node.attrs.colspan ?? 1; + const rowspan = node.attrs.rowspan ?? 1; + return colspan > 1 || rowspan > 1; +} + +/** + * Generic function to collect cells along a row or column + */ +function collectCells( + editor: Editor | null, + orientation: Orientation, + index?: number, + tablePos?: number +): { cells: CellInfo[]; mergedCells: CellInfo[] } { + if (!editor) return EMPTY_CELLS_RESULT; + + const { state } = editor; + const table = getTable(editor, tablePos); + if (!table) return EMPTY_CELLS_RESULT; + + const tableStart = table.start; + const tableNode = table.node; + const map = table.map; + + const resolvedIndex = resolveOrientationIndex( + state, + table, + orientation, + index + ); + if (resolvedIndex === null) return EMPTY_CELLS_RESULT; + + // Bounds check + const maxIndex = orientation === 'row' ? map.height : map.width; + if (resolvedIndex < 0 || resolvedIndex >= maxIndex) { + return EMPTY_CELLS_RESULT; + } + + const cells: CellInfo[] = []; + const mergedCells: CellInfo[] = []; + const seenMerged = new Set(); + + const iterationCount = orientation === 'row' ? map.width : map.height; + + for (let i = 0; i < iterationCount; i++) { + const row = orientation === 'row' ? resolvedIndex : i; + const col = orientation === 'row' ? i : resolvedIndex; + const cellIndex = row * map.width + col; + const mapCell = map.map[cellIndex]; + + if (mapCell === undefined) continue; + + const cellPos = tableStart + mapCell; + const cellNode = tableNode.nodeAt(mapCell); + if (!cellNode) continue; + + const cell = createCellInfo(row, col, cellPos, cellNode); + + if (isCellMerged(cellNode)) { + if (!seenMerged.has(cellPos)) { + mergedCells.push(cell); + seenMerged.add(cellPos); + } + } + + cells.push(cell); + } + + return { cells, mergedCells }; +} + +/** + * Generic function to count empty cells from the end of a row or column + */ +function countEmptyCellsFromEnd( + editor: Editor, + tablePos: number, + orientation: Orientation +): number { + const table = getTable(editor, tablePos); + if (!table) return 0; + + const { doc } = editor.state; + const maxIndex = orientation === 'row' ? table.map.height : table.map.width; + + let emptyCount = 0; + for (let idx = maxIndex - 1; idx >= 0; idx--) { + const seen = new Set(); + let isLineEmpty = true; + + const iterationCount = + orientation === 'row' ? table.map.width : table.map.height; + + for (let i = 0; i < iterationCount; i++) { + const row = orientation === 'row' ? idx : i; + const col = orientation === 'row' ? i : idx; + const rel = table.map.positionAt(row, col, table.node); + + if (seen.has(rel)) continue; + seen.add(rel); + + const abs = tablePos + 1 + rel; + const cell = doc.nodeAt(abs); + if (!cell) continue; + + if (!isCellEmpty(cell)) { + isLineEmpty = false; + break; + } + } + + if (isLineEmpty) emptyCount++; + else break; + } + + return emptyCount; +} + +/** + * Get information about the table at the current selection or a specific position. + * + * If `tablePos` is provided, it looks for a table at that exact position. + * Otherwise, it finds the nearest table containing the current selection. + * + * Returns an object with: + * - `node`: the table node + * - `pos`: the position of the table in the document + * - `start`: the position just after the table node (where its content starts) + * - `map`: the `TableMap` for layout info (rows, columns, spans) + * + * If no table is found, returns null. + */ +export function getTable(editor: Editor | null, tablePos?: number) { + if (!editor) return null; + + let table = null; + + if (typeof tablePos === 'number') { + const tableNode = editor.state.doc.nodeAt(tablePos); + if (tableNode?.type.name === 'table') { + table = { + node: tableNode, + pos: tablePos, + start: tablePos + 1, + depth: editor.state.doc.resolve(tablePos).depth + }; + } + } + + if (!table) { + const { state } = editor; + const $from = state.doc.resolve(state.selection.from); + table = findTable($from); + } + + if (!table) return null; + + const tableMap = TableMap.get(table.node); + if (!tableMap) return null; + + return { ...table, map: tableMap }; +} + +/** + * Checks if the current text selection is inside a table cell. + * @param state - The editor state to check + * @returns true if the selection is inside a table cell; false otherwise + */ +export function isSelectionInCell(state: EditorState): boolean { + const { selection } = state; + const $from = selection.$from; + + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + return true; + } + } + + return false; +} + +/** + * Cells overlap a rectangle if any of the cells in the rectangle are merged + * with cells outside the rectangle. + */ +export function cellsOverlapRectangle( + { width, height, map }: TableMap, + rect: Rect +) { + let indexTop = rect.top * width + rect.left, + indexLeft = indexTop; + let indexBottom = (rect.bottom - 1) * width + rect.left, + indexRight = indexTop + (rect.right - rect.left - 1); + for (let i = rect.top; i < rect.bottom; i++) { + if ( + (rect.left > 0 && map[indexLeft] == map[indexLeft - 1]) || + (rect.right < width && map[indexRight] == map[indexRight + 1]) + ) + return true; + indexLeft += width; + indexRight += width; + } + for (let i = rect.left; i < rect.right; i++) { + if ( + (rect.top > 0 && map[indexTop] == map[indexTop - width]) || + (rect.bottom < height && map[indexBottom] == map[indexBottom + width]) + ) + return true; + indexTop++; + indexBottom++; + } + return false; +} + +/** + * Runs a function while preserving the editor's selection. + * @param editor The Tiptap editor instance + * @param fn The function to run + * @returns True if the selection was successfully restored, false otherwise + */ +export function runPreservingCursor(editor: Editor, fn: () => void): boolean { + const view = editor.view; + const startSel = view.state.selection; + const bookmark = startSel.getBookmark(); + + const mapping = new Mapping(); + const originalDispatch = view.dispatch; + + view.dispatch = (tr) => { + mapping.appendMapping(tr.mapping); + originalDispatch(tr); + }; + + try { + fn(); + } finally { + view.dispatch = originalDispatch; + } + + try { + const sel = bookmark.map(mapping).resolve(view.state.doc); + view.dispatch(view.state.tr.setSelection(sel)); + return true; + } catch { + // Fallback: if the exact spot vanished (e.g., cell deleted), + // go to the nearest valid position. + const mappedPos = mapping.map(startSel.from, -1); + const clamped = clamp(mappedPos, 0, view.state.doc.content.size); + const near = Selection.near(view.state.doc.resolve(clamped), -1); + view.dispatch(view.state.tr.setSelection(near)); + return false; + } +} + +/** + * Determines whether a table cell is effectively empty. + * + * A cell is considered empty when: + * - it has no children, or + * - it contains only whitespace text, or + * - it contains no text and no non-text leaf nodes (images, embeds, etc.) + * + * Early-outs as soon as any meaningful content is found. + * + * @param cellNode - The table cell node to check + * @returns true if the cell is empty; false otherwise + */ +export function isCellEmpty(cellNode: Node): boolean { + if (cellNode.childCount === 0) return true; + + let isEmpty = true; + cellNode.descendants((n) => { + if (n.isText && n.text?.trim()) { + isEmpty = false; + return false; + } + if (n.isLeaf && !n.isText) { + isEmpty = false; + return false; + } + return true; + }); + + return isEmpty; +} + +/** + * Determine if the current selection is a full row or column selection. + * + * If the selection is a `CellSelection` that spans an entire row or column, + * returns an object indicating the type and index: + * - `{ type: "row", index: number }` for full row selections + * - `{ type: "column", index: number }` for full column selections + * + * If the selection is not a full row/column, or if no table is found, returns null. + */ +export function getTableSelectionType( + editor: Editor | null, + index?: number, + orientation?: Orientation, + tablePos?: number +): { orientation: Orientation; index: number } | null { + if (typeof index === 'number' && orientation) { + return { orientation, index }; + } + + if (!editor) return null; + + const { state } = editor; + + const table = getTable(editor, tablePos); + if (!table) return null; + + if (state.selection instanceof CellSelection) { + const rect = selectedRect(state); + const width = rect.right - rect.left; + const height = rect.bottom - rect.top; + + if (height === 1 && width >= 1) { + return { orientation: 'row', index: rect.top }; + } + + if (width === 1 && height >= 1) { + return { orientation: 'column', index: rect.left }; + } + + return null; + } + + return null; +} + +/** + * Get all cells (and unique merged cells) in the selected row or column. + * + * - If `index` is provided, uses that row/column index. + * - If omitted, uses the first selected row/column based on current selection. + * + * Returns an object with: + * - `cells`: all cells in the row/column + * - `mergedCells`: only the unique cells that have rowspan/colspan > 1 + * + * If no valid selection or index is found, returns empty arrays. + */ +export function getRowOrColumnCells( + editor: Editor | null, + index?: number, + orientation?: Orientation, + tablePos?: number +): { + cells: CellInfo[]; + mergedCells: CellInfo[]; + index?: number; + orientation?: Orientation; + tablePos?: number; +} { + const emptyResult = { + cells: [], + mergedCells: [], + index: undefined, + orientation: undefined, + tablePos: undefined + }; + + if (!editor) { + return emptyResult; + } + + if ( + typeof index !== 'number' && + !(editor.state.selection instanceof CellSelection) + ) { + return emptyResult; + } + + let finalIndex = index; + let finalOrientation = orientation; + + if ( + typeof finalIndex !== 'number' || + !finalOrientation || + !['row', 'column'].includes(finalOrientation) + ) { + const selectionType = getTableSelectionType(editor); + if (!selectionType) return emptyResult; + + finalIndex = selectionType.index; + finalOrientation = selectionType.orientation; + } + + const result = collectCells(editor, finalOrientation, finalIndex, tablePos); + return { ...result, index: finalIndex, orientation: finalOrientation }; +} + +/** + * Collect cells (and unique merged cells) from a specific row. + * - If `rowIndex` is provided, scans that row. + * - If omitted, uses the first (topmost) selected row based on the current selection. + */ +export function getRowCells( + editor: Editor | null, + rowIndex?: number, + tablePos?: number +): { cells: CellInfo[]; mergedCells: CellInfo[] } { + return collectCells(editor, 'row', rowIndex, tablePos); +} + +/** + * Collect cells (and unique merged cells) from the current table. + * - If `columnIndex` is provided, scans that column. + * - If omitted, uses the first (leftmost) selected column based on the current selection. + */ +export function getColumnCells( + editor: Editor | null, + columnIndex?: number, + tablePos?: number +): { cells: CellInfo[]; mergedCells: CellInfo[] } { + return collectCells(editor, 'column', columnIndex, tablePos); +} + +/** + * After moving a row or column, update the selection to the moved item. + * + * This ensures that after a move operation, the selection remains on the + * moved row or column, providing better user feedback. + * + * @param editor - The editor instance + * @param orientation - "row" or "column" indicating what was moved + * @param newIndex - The new index of the moved row/column + * @param tablePos - Optional position of the table in the document + */ +export function updateSelectionAfterAction( + editor: Editor, + orientation: Orientation, + newIndex: number, + tablePos?: number +): void { + try { + const table = getTable(editor, tablePos); + if (!table) return; + + const { state } = editor; + const { map } = table; + + if (orientation === 'row') { + if (newIndex >= 0 && newIndex < map.height) { + const startCol = 0; + const endCol = map.width - 1; + + const startCellPos = + table.start + map.positionAt(newIndex, startCol, table.node); + const endCellPos = + table.start + map.positionAt(newIndex, endCol, table.node); + + const $start = state.doc.resolve(startCellPos); + const $end = state.doc.resolve(endCellPos); + + const newSelection = CellSelection.create( + state.doc, + $start.pos, + $end.pos + ); + const tr = state.tr.setSelection(newSelection); + editor.view.dispatch(tr); + } + } else if (orientation === 'column') { + if (newIndex >= 0 && newIndex < map.width) { + const startRow = 0; + const endRow = map.height - 1; + + const startCellPos = + table.start + map.positionAt(startRow, newIndex, table.node); + const endCellPos = + table.start + map.positionAt(endRow, newIndex, table.node); + + const $start = state.doc.resolve(startCellPos); + const $end = state.doc.resolve(endCellPos); + + const newSelection = CellSelection.create( + state.doc, + $start.pos, + $end.pos + ); + const tr = state.tr.setSelection(newSelection); + editor.view.dispatch(tr); + } + } + } catch (error) { + console.warn('Failed to update selection after move:', error); + } +} + +/** + * Returns a command that sets the given attributes to the given values, + * and is only available when the currently selected cell doesn't + * already have those attributes set to those values. + * + * @public + */ +export function setCellAttr(attrs: Record): Command; +export function setCellAttr(name: string, value: unknown): Command; +export function setCellAttr( + nameOrAttrs: string | Record, + value?: unknown +): Command { + return function (state, dispatch) { + if (!isInTable(state)) return false; + const $cell = selectionCell(state); + + const attrs = + typeof nameOrAttrs === 'string' ? { [nameOrAttrs]: value } : nameOrAttrs; + + if (dispatch) { + const tr = state.tr; + if (state.selection instanceof CellSelection) { + state.selection.forEachCell((node, pos) => { + const needsUpdate = Object.entries(attrs).some( + ([name, val]) => node.attrs[name] !== val + ); + + if (needsUpdate) { + tr.setNodeMarkup(pos, null, { + ...node.attrs, + ...attrs + }); + } + }); + } else { + const needsUpdate = Object.entries(attrs).some( + ([name, val]) => $cell.nodeAfter!.attrs[name] !== val + ); + + if (needsUpdate) { + tr.setNodeMarkup($cell.pos, null, { + ...$cell.nodeAfter!.attrs, + ...attrs + }); + } + } + dispatch(tr); + } + return true; + }; +} + +/** + * Counts how many consecutive empty rows exist at the bottom of a given table. + * + * This function: + * - Locates the exact table in the document via reference matching + * - Iterates from the last visual row upward + * - Deduplicates cells per row using `TableMap` (merged cells can repeat positions) + * - Treats a row as empty only if all its unique cells are empty by `isCellEmpty` + * + * @param editor - The editor whose document contains the table + * @param target - The table node instance to analyze (must be the same reference as in the doc) + * @returns The number of trailing empty rows (0 if table not found) + */ +export function countEmptyRowsFromEnd( + editor: Editor, + tablePos: number +): number { + return countEmptyCellsFromEnd(editor, tablePos, 'row'); +} + +/** + * Counts how many consecutive empty columns exist at the right edge of a given table. + * + * Similar to `countEmptyRowsFromEnd`, but scans by columns: + * - Iterates from the last visual column leftward + * - Deduplicates per-column cells using `TableMap` + * - A column is empty only if all unique cells in that column are empty + * + * @param editor - The editor whose document contains the table + * @param target - The table node instance to analyze (must be the same reference as in the doc) + * @returns The number of trailing empty columns (0 if table not found) + */ +export function countEmptyColumnsFromEnd( + editor: Editor, + tablePos: number +): number { + return countEmptyCellsFromEnd(editor, tablePos, 'column'); +} + +/** + * Rounds a number with a symmetric "dead-zone" around integer boundaries, + * which makes drag/resize UX feel less jittery near thresholds. + * + * For example, with `margin = 0.3`: + * - values < n + 0.3 snap down to `n` + * - values > n + 0.7 snap up to `n + 1` + * - values in [n + 0.3, n + 0.7] fall back to `Math.round` + * + * @param num - The floating value to round + * @param margin - Half-width of the dead-zone around integer boundaries (default 0.3) + * @returns The rounded value using the dead-zone heuristic + */ +export function marginRound(num: number, margin = 0.3): number { + const floor = Math.floor(num); + const ceil = Math.ceil(num); + const lowerBound = floor + margin; + const upperBound = ceil - margin; + + if (num < lowerBound) return floor; + if (num > upperBound) return ceil; + return Math.round(num); +} + +/** + * Compares two DOMRect objects for equality. + * + * Treats `undefined` as a valid state, where two `undefined` rects are equal, + * and `undefined` is not equal to any defined rect. + * + * @param a - The first DOMRect or undefined + * @param b - The second DOMRect or undefined + * @returns true if both rects are equal or both are undefined; false otherwise + */ +export function rectEq(a?: DOMRect | null, b?: DOMRect | null): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + return ( + a.left === b.left && + a.top === b.top && + a.width === b.width && + a.height === b.height + ); +} + +/** + * Applies the transaction based on the specified mode + */ +function applySelectionWithMode( + state: EditorState, + transaction: Transaction, + options: BaseSelectionOptions | DispatchSelectionOptions +): EditorState | Transaction | void { + const mode: SelectionReturnMode = options.mode ?? 'state'; + + switch (mode) { + case 'dispatch': { + const dispatchOptions = options as DispatchSelectionOptions; + if (typeof dispatchOptions.dispatch === 'function') { + dispatchOptions.dispatch(transaction); + } + return; + } + + case 'transaction': + return transaction; + + default: // "state" + return state.apply(transaction); + } +} + +/** + * Create or apply a `CellSelection` inside a table. + * + * Depending on the `mode` option, this helper behaves differently: + * + * - `"state"` (default) → Returns a new `EditorState` with the selection applied. + * - `"transaction"` → Returns a `Transaction` with the selection set, without applying it. + * - `"dispatch"` → Immediately calls `dispatch(tr)` with the new selection. + * + * This allows you to reuse the same helper in commands, tests, or utilities + * without duplicating logic. + * + * Example: + * ```ts + * // Get new state + * const nextState = createTableCellSelection(state, tablePosition, { row: 1, col: 1 }, { row: 2, col: 3 }) + * + * // Get transaction only + * const tr = createTableCellSelection(state, tablePosition, { row: 0, col: 0 }, { row: 0, col: 2 }, { mode: "transaction" }) + * + * // Dispatch directly + * createTableCellSelection(state, tablePosition, { row: 1, col: 1 }, { row: 3, col: 2 }, { mode: "dispatch", dispatch }) + * ``` + */ +export function createTableCellSelection( + state: EditorState, + tablePosition: number, + startCell: CellCoordinates, + endCell?: CellCoordinates, + options?: StateSelectionOptions +): EditorState; +export function createTableCellSelection( + state: EditorState, + tablePosition: number, + startCell: CellCoordinates, + endCell: CellCoordinates | undefined, + options: TransactionSelectionOptions +): Transaction; +export function createTableCellSelection( + state: EditorState, + tablePosition: number, + startCell: CellCoordinates, + endCell: CellCoordinates | undefined, + options: DispatchSelectionOptions +): void; + +export function createTableCellSelection( + state: EditorState, + tablePosition: number, + startCell: CellCoordinates, + endCell: CellCoordinates = startCell, + options: BaseSelectionOptions | DispatchSelectionOptions = { mode: 'state' } +): EditorState | Transaction | void { + const startCellPosition = getCellPosition(state, tablePosition, startCell); + const endCellPosition = getCellPosition(state, tablePosition, endCell); + + if (!startCellPosition || !endCellPosition) { + return; + } + + const transaction = state.tr.setSelection( + new CellSelection(startCellPosition, endCellPosition) + ); + + return applySelectionWithMode(state, transaction, options); +} + +/** + * Get the position of a cell inside a table by relative row/col indices. + * Returns the position *before* the cell, which is what `CellSelection` expects. + */ +export function getCellPosition( + state: EditorState, + tablePosition: number, + cellCoordinates: CellCoordinates +) { + const resolvedTablePosition = state.doc.resolve(tablePosition); + const resolvedRowPosition = state.doc.resolve( + resolvedTablePosition.posAtIndex(cellCoordinates.row) + 1 + ); + const resolvedColPosition = state.doc.resolve( + resolvedRowPosition.posAtIndex(cellCoordinates.col) + ); + + const $cell = cellAround(resolvedColPosition); + if (!$cell) return null; + + return resolvedColPosition; +} + +/** + * Selects table cells by their (row, col) coordinates. + * + * This function can be used in three modes: + * - `"state"` (default) → Returns a new `EditorState` with the selection applied, or null if failed. + * - `"transaction"` → Returns a `Transaction` with the selection set, or null if failed. + * - `"dispatch"` → Immediately dispatches the selection and returns boolean success status. + * + * @param editor - The editor instance + * @param tablePos - Position of the table in the document + * @param coords - Array of {row, col} coordinates to select + * @param options - Mode and dispatch options + */ +export function selectCellsByCoords( + editor: Editor | null, + tablePos: number, + coords: { row: number; col: number }[], + options?: StateSelectionOptions +): EditorState; +export function selectCellsByCoords( + editor: Editor | null, + tablePos: number, + coords: { row: number; col: number }[], + options: TransactionSelectionOptions +): Transaction; +export function selectCellsByCoords( + editor: Editor | null, + tablePos: number, + coords: { row: number; col: number }[], + options: DispatchSelectionOptions +): void; +export function selectCellsByCoords( + editor: Editor | null, + tablePos: number, + coords: { row: number; col: number }[], + options: BaseSelectionOptions | DispatchSelectionOptions = { mode: 'state' } +): EditorState | Transaction | void { + if (!editor) return; + + const table = getTable(editor, tablePos); + if (!table) return; + + const { state } = editor; + const tableMap = table.map; + + const cleanedCoords = coords + .map((coord) => ({ + row: clamp(coord.row, 0, tableMap.height - 1), + col: clamp(coord.col, 0, tableMap.width - 1) + })) + .filter((coord) => isWithinBounds(coord.row, coord.col, tableMap)); + + if (cleanedCoords.length === 0) { + return; + } + + // --- Find the smallest rectangle that contains all our coordinates --- + const allRows = cleanedCoords.map((coord) => coord.row); + const topRow = Math.min(...allRows); + const bottomRow = Math.max(...allRows); + + const allCols = cleanedCoords.map((coord) => coord.col); + const leftCol = Math.min(...allCols); + const rightCol = Math.max(...allCols); + + // --- Convert visual coordinates to document positions --- + // Use TableMap.map array directly to handle merged cells correctly + const getCellPositionFromMap = (row: number, col: number): number | null => { + // TableMap.map is a flat array where each entry represents a cell + // For merged cells, the same offset appears multiple times + const cellOffset = tableMap.map[row * tableMap.width + col]; + if (cellOffset === undefined) return null; + + // Convert the relative offset to an absolute position in the document + // tablePos + 1 skips the table opening tag + return tablePos + 1 + cellOffset; + }; + + // Anchor = where the selection starts (top-left of bounding box) + const anchorPosition = getCellPositionFromMap(topRow, leftCol); + if (anchorPosition === null) return; + + // Head = where the selection ends (usually bottom-right of bounding box) + let headPosition = getCellPositionFromMap(bottomRow, rightCol); + if (headPosition === null) return; + + // --- Handle edge case with merged cells --- + // If anchor and head point to the same cell, we need to find a different head + // This happens when selecting a single merged cell or when all coords point to one cell + if (headPosition === anchorPosition) { + let foundDifferentCell = false; + + // Search backwards from bottom-right to find a cell with a different position + for (let row = bottomRow; row >= topRow && !foundDifferentCell; row--) { + for (let col = rightCol; col >= leftCol && !foundDifferentCell; col--) { + const candidatePosition = getCellPositionFromMap(row, col); + + if ( + candidatePosition !== null && + candidatePosition !== anchorPosition + ) { + headPosition = candidatePosition; + foundDifferentCell = true; + } + } + } + } + + try { + const anchorRef = state.doc.resolve(anchorPosition); + const headRef = state.doc.resolve(headPosition); + + const cellSelection = new CellSelection(anchorRef, headRef); + const transaction = state.tr.setSelection(cellSelection); + + return applySelectionWithMode(state, transaction, options); + } catch (error) { + console.error('Failed to create cell selection:', error); + return; + } +} + +/** + * Select the cell at (row, col) using `cellAround` to respect merged cells. + * + * @param editor Tiptap editor + * @param row Row index (0-based) + * @param col Column index (0-based) + * @param tablePos Optional absolute position of the table node + * @param dispatch Optional dispatch; defaults to editor.view.dispatch + */ +export function selectCellAt({ + editor, + row, + col, + tablePos, + dispatch +}: { + editor: Editor | null; + row: number; + col: number; + tablePos?: number; + dispatch?: (tr: Transaction) => void; +}): boolean { + if (!editor) return false; + + const { state, view } = editor; + const found = getTable(editor, tablePos); + if (!found) return false; + + // Bounds check + if (!isWithinBounds(row, col, found.map)) { + return false; + } + + const relCellPos = found.map.positionAt(row, col, found.node); + const absCellPos = found.start + relCellPos; + + const $abs = state.doc.resolve(absCellPos); + const $cell = cellAround($abs); + const cellPos = $cell ? $cell.pos : absCellPos; + + const sel = CellSelection.create(state.doc, cellPos); + + const doDispatch = dispatch ?? view?.dispatch; + if (!doDispatch) return false; + + doDispatch(state.tr.setSelection(sel)); + return true; +} + +/** + * Selects a boundary cell of the table based on orientation. + * + * For row orientation, selects the bottom-left cell of the table. + * For column orientation, selects the top-right cell of the table. + * + * This function accounts for merged cells to ensure the correct cell is selected. + * + * @param editor The Tiptap editor instance + * @param tableNode The table node + * @param tablePos The position of the table node in the document + * @param orientation "row" to select bottom-left, "column" to select top-right + * @returns true if the selection was successful; false otherwise + */ +export function selectLastCell( + editor: Editor, + tableNode: Node, + tablePos: number, + orientation: Orientation +) { + const map = TableMap.get(tableNode); + const isRow = orientation === 'row'; + + // For rows, select bottom-left cell; for columns, select top-right cell + const row = isRow ? map.height - 1 : 0; + const col = isRow ? 0 : map.width - 1; + + // Calculate the index in the table map + const index = row * map.width + col; + + // Get the actual cell position from the map (handles merged cells) + const cellPos = map.map[index]; + if (!cellPos && cellPos !== 0) { + console.warn('selectLastCell: cell position not found in map', { + index, + row, + col, + map + }); + return false; + } + + // Find the row and column of the actual cell + const cellIndex = map.map.indexOf(cellPos); + const actualRow = cellIndex >= 0 ? Math.floor(cellIndex / map.width) : 0; + const actualCol = cellIndex >= 0 ? cellIndex % map.width : 0; + + return selectCellAt({ + editor, + row: actualRow, + col: actualCol, + tablePos, + dispatch: editor.view.dispatch.bind(editor.view) + }); +} + +/** + * Get all (row, col) coordinates for a given row or column index. + * + * - If `orientation` is "row", returns all columns in that row. + * - If `orientation` is "column", returns all rows in that column. + * + * Returns null if: + * - the editor or table is not found + * - the index is out of bounds + * + * @param editor The Tiptap editor instance + * @param index The row or column index (0-based) + * @param orientation "row" to get row coordinates, "column" for column coordinates + * @param tablePos Optional position of the table node in the document + * @returns Array of {row, col} objects or null if invalid + */ +export function getIndexCoordinates({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index: number; + orientation?: Orientation; + tablePos?: number; +}): { row: number; col: number }[] | null { + if (!editor) return null; + + const table = getTable(editor, tablePos); + if (!table) return null; + + const { map } = table; + const { width, height } = map; + + if (index < 0) return null; + if (orientation === 'row' && index >= height) return null; + if (orientation === 'column' && index >= width) return null; + + return orientation === 'row' + ? Array.from({ length: map.width }, (_, col) => ({ row: index, col })) + : Array.from({ length: map.height }, (_, row) => ({ row, col: index })); +} + +/** + * Given a DOM cell element, find its (row, col) indices within the table. + * + * This function: + * - Locates the nearest ancestor table element + * - Uses the editor's document model to resolve the cell's position + * - Traverses up the node hierarchy to find the corresponding table cell node + * - Uses `TableMap` to translate the cell's position into (row, col) indices + * + * Returns null if: + * - the table or cell cannot be found in the editor's document + * - any error occurs during position resolution + * + * @param cell The HTMLTableCellElement (td or th) + * @param tableNode The table node in the ProseMirror document + * @param editor The Tiptap editor instance + * @returns An object with { rowIndex, colIndex } or null if not found + */ +export function getCellIndicesFromDOM( + cell: HTMLTableCellElement, + tableNode: Node | null, + editor: Editor +): { rowIndex: number; colIndex: number } | null { + if (!tableNode) return null; + + try { + const cellPos = editor.view.posAtDOM(cell, 0); + const $cellPos = editor.view.state.doc.resolve(cellPos); + + for (let d = $cellPos.depth; d > 0; d--) { + const node = $cellPos.node(d); + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + const tableMap = TableMap.get(tableNode); + const cellNodePos = $cellPos.before(d); + const tableStart = $cellPos.start(d - 2); + const cellOffset = cellNodePos - tableStart; + const cellIndex = tableMap.map.indexOf(cellOffset); + + return { + rowIndex: Math.floor(cellIndex / tableMap.width), + colIndex: cellIndex % tableMap.width + }; + } + } + } catch (error) { + console.warn('Could not get cell position:', error); + } + return null; +} + +/** + * Given a DOM element inside a table, find the corresponding table node and its position. + * + * This function: + * - Locates the nearest ancestor table element + * - Uses the editor's document model to resolve the table's position + * - Traverses up the node hierarchy to find the corresponding table node + * + * Returns null if: + * - the table cannot be found in the editor's document + * - any error occurs during position resolution + * + * @param tableElement The HTMLTableElement or an element inside it + * @param editor The Tiptap editor instance + * @returns An object with { node: tableNode, pos: tablePos } or null if not found + */ +export function getTableFromDOM( + tableElement: HTMLElement, + editor: Editor +): { node: Node; pos: number } | null { + try { + const pos = editor.view.posAtDOM(tableElement, 0); + const $pos = editor.view.state.doc.resolve(pos); + + for (let d = $pos.depth; d >= 0; d--) { + const node = $pos.node(d); + if (isTableNode(node)) { + return { node, pos: d === 0 ? 0 : $pos.before(d) }; + } + } + } catch (error) { + console.warn('Could not get table from DOM:', error); + } + return null; +} + +/** + * Checks if a node is a table node + */ +export function isTableNode(node: Node | null | undefined): node is Node { + return ( + !!node && + (node.type.name === 'table' || node.type.spec.tableRole === 'table') + ); +} diff --git a/packages/editor/src/components/tiptap-node/table-node/styles/prosemirror-table.scss b/packages/editor/src/components/tiptap-node/table-node/styles/prosemirror-table.scss new file mode 100644 index 00000000..f639338e --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/styles/prosemirror-table.scss @@ -0,0 +1,48 @@ +.ProseMirror .tableWrapper { + overflow-x: auto; +} +.ProseMirror table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + overflow: hidden; +} +.ProseMirror td, +.ProseMirror th { + vertical-align: top; + box-sizing: border-box; + position: relative; +} + +.ProseMirror td:not([data-colwidth]):not(.column-resize-dragging), +.ProseMirror th:not([data-colwidth]):not(.column-resize-dragging) { + /* if there's no explicit width set and the column is not being resized, set a default width */ + min-width: var(--default-cell-min-width); +} + +.ProseMirror .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 4px; + z-index: 20; + background-color: #adf; + pointer-events: none; +} +.ProseMirror.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} +/* Give selected cells a blue overlay */ +.ProseMirror .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(200, 200, 255, 0.4); + pointer-events: none; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/styles/table-node.scss b/packages/editor/src/components/tiptap-node/table-node/styles/table-node.scss new file mode 100644 index 00000000..a16d1b47 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/styles/table-node.scss @@ -0,0 +1,172 @@ +:root { + --tt-table-border-color: var(--tt-gray-light-a-300); + --tt-table-selected-bg: rgba(195, 189, 255, 0.4); + --tt-table-selected-stroke: var(--tt-brand-color-400); + --tt-table-column-resize-handle-bg: var(--tt-brand-color-400); + + --tt-table-cell-padding: 0.5rem; + --tt-table-margin-block: 1.25rem; + + --tt-table-pad-block-start: 1rem; /* 8px */ + --tt-table-pad-block-end: 1.5rem; /* 24px */ + --tt-table-pad-inline-start: 1rem; /* 8px */ + --tt-table-pad-inline-end: 1.5rem; /* 24px */ +} + +.dark { + --tt-table-border-color: var(--tt-gray-dark-a-300); + --tt-table-selected-bg: rgba(195, 189, 255, 0.2); + --tt-table-selected-stroke: var(--tt-brand-color-400); + --tt-table-column-resize-handle-bg: var(--tt-brand-color-400); +} + +.tiptap [data-content-type="table"] { + margin-block: var(--tt-table-margin-block); +} + +.tiptap [data-content-type="table"] .tableWrapper { + padding-block-start: var(--tt-table-pad-block-start); + padding-inline-start: var(--tt-table-pad-inline-start); + padding-inline-end: var(--tt-table-pad-inline-end); + padding-block-end: var(--tt-table-pad-block-end); + margin-left: -1rem; + overflow-y: hidden; + position: relative; + width: 100%; +} + +.tiptap table { + width: auto !important; + word-break: break-word; +} + +.tiptap th, +.tiptap td { + border: 1px solid var(--tt-table-border-color); + padding: var(--tt-table-cell-padding); +} + +.tiptap table th { + background-color: var(--tt-gray-light-a-100); + font-weight: 700; + text-align: left; +} + +.ProseMirror .column-resize-handle { + position: absolute; + top: 0; + right: 0; + width: 2px; + height: 100%; + margin-inline-start: -1px; + margin-top: -1px; + height: calc(100% + 2px); + background: var(--tt-table-column-resize-handle-bg); + cursor: col-resize; + transition: background 150ms 50ms; + z-index: 1; + pointer-events: auto; +} + +/* ================================================================================================ + * TABLE CELL ALIGNMENT STYLES + * ================================================================================================ */ + +.tiptap td[data-text-align="left"], +.tiptap th[data-text-align="left"] { + text-align: left; +} + +.tiptap td[data-text-align="center"], +.tiptap th[data-text-align="center"] { + text-align: center; +} + +.tiptap td[data-text-align="right"], +.tiptap th[data-text-align="right"] { + text-align: right; +} + +.tiptap td[data-text-align="justify"], +.tiptap th[data-text-align="justify"] { + text-align: justify; +} + +.tiptap td[data-vertical-align="top"], +.tiptap th[data-vertical-align="top"] { + vertical-align: top; +} + +.tiptap td[data-vertical-align="middle"], +.tiptap th[data-vertical-align="middle"] { + vertical-align: middle; +} + +.tiptap td[data-vertical-align="bottom"], +.tiptap th[data-vertical-align="bottom"] { + vertical-align: bottom; +} + +.tiptap [data-content-type="table"] td[data-text-align="left"], +.tiptap [data-content-type="table"] th[data-text-align="left"] { + text-align: left; +} + +.tiptap [data-content-type="table"] td[data-text-align="center"], +.tiptap [data-content-type="table"] th[data-text-align="center"] { + text-align: center; +} + +.tiptap [data-content-type="table"] td[data-text-align="right"], +.tiptap [data-content-type="table"] th[data-text-align="right"] { + text-align: right; +} + +.tiptap [data-content-type="table"] td[data-text-align="justify"], +.tiptap [data-content-type="table"] th[data-text-align="justify"] { + text-align: justify; +} + +.tiptap [data-content-type="table"] td[data-vertical-align="top"], +.tiptap [data-content-type="table"] th[data-vertical-align="top"] { + vertical-align: top; +} + +.tiptap [data-content-type="table"] td[data-vertical-align="middle"], +.tiptap [data-content-type="table"] th[data-vertical-align="middle"] { + vertical-align: middle; +} + +.tiptap [data-content-type="table"] td[data-vertical-align="bottom"], +.tiptap [data-content-type="table"] th[data-vertical-align="bottom"] { + vertical-align: bottom; +} + +/* tiptap uses colwidth instead of data-colwidth, se we need to adjust this style from prosemirror-tables */ +.ProseMirror td, +.ProseMirror th { + min-width: auto !important; +} +.ProseMirror td:not([colwidth]):not(.column-resize-dragging), +.ProseMirror th:not([colwidth]):not(.column-resize-dragging) { + /* if there's no explicit width set and the column is not being resized, set a default width */ + min-width: var(--default-cell-min-width) !important; +} + +.tiptap-table-dropcursor { + position: absolute; + z-index: 20; + background-color: var(--tt-table-column-resize-handle-bg); + pointer-events: none; +} + +.table-cell-dragging-source { + z-index: 2; + position: absolute; + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(200, 200, 255, 0.4); +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/index.tsx new file mode 100644 index 00000000..b3996fe6 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-add-row-column-button'; +export * from './use-table-add-row-column'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/table-add-row-column-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/table-add-row-column-button.tsx new file mode 100644 index 00000000..e7f29721 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/table-add-row-column-button.tsx @@ -0,0 +1,97 @@ +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableAddRowColumnConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-add-row-column-button'; +import { useTableAddRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-add-row-column-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableAddRowColumnButtonProps + extends Omit, UseTableAddRowColumnConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for adding a table row/column in a Tiptap editor. + * + * For custom button implementations, use the `useTableAddRowColumn` hook instead. + */ +export const TableAddRowColumnButton = forwardRef< + HTMLButtonElement, + TableAddRowColumnButtonProps +>( + ( + { + editor: providedEditor, + index, + orientation, + side, + tablePos, + hideWhenUnavailable = false, + onAdded, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleAdd, label, canAddRowColumn, Icon } = + useTableAddRowColumn({ + editor, + index, + orientation, + side, + tablePos, + hideWhenUnavailable, + onAdded + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleAdd(); + }, + [handleAdd, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableAddRowColumnButton.displayName = 'TableAddRowColumnButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/use-table-add-row-column.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/use-table-add-row-column.ts new file mode 100644 index 00000000..459e8431 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-add-row-column-button/use-table-add-row-column.ts @@ -0,0 +1,346 @@ +'use client'; + +import { useCallback } from 'react'; +import type { Node } from '@tiptap/pm/model'; +import type { Transaction } from '@tiptap/pm/state'; +import type { TableMap } from '@tiptap/pm/tables'; +import { + addColumnAfter, + addColumnBefore, + addRowAfter, + addRowBefore, + CellSelection, + columnIsHeader, + rowIsHeader +} from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { AddColLeftIcon } from '@workspace/editor/components/tiptap-icons/add-col-left-icon'; +import { AddColRightIcon } from '@workspace/editor/components/tiptap-icons/add-col-right-icon'; +import { AddRowBottomIcon } from '@workspace/editor/components/tiptap-icons/add-row-bottom-icon'; +import { AddRowTopIcon } from '@workspace/editor/components/tiptap-icons/add-row-top-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + getTable, + getTableSelectionType, + selectCellsByCoords, + updateSelectionAfterAction +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export type RowSide = 'above' | 'below'; +export type ColSide = 'left' | 'right'; + +export interface UseTableAddRowColumnConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The index of the row or column to add relative to. + * If omitted, will use the current selection. + */ + index?: number; + /** + * Whether you're adding a row or a column. + * If omitted, will use the current selection. + */ + orientation?: Orientation; + /** + * The side to add on - above/below for rows, left/right for columns. + */ + side: RowSide | ColSide; + /** + * The position of the table in the document. + */ + tablePos?: number; + /** + * Hide the button when addition isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after a successful addition. + */ + onAdded?: () => void; +} + +const REQUIRED_EXTENSIONS = ['table']; + +export const tableAddRowColumnLabels = { + row: { + above: 'Insert row above', + below: 'Insert row below' + } as Record, + column: { + left: 'Insert column left', + right: 'Insert column right' + } as Record +} as const; + +function safeColumnIsHeader(map: TableMap, node: Node, index: number): boolean { + try { + return columnIsHeader(map, node, index); + } catch { + return false; + } +} + +function safeRowIsHeader(map: TableMap, node: Node, index: number): boolean { + try { + return rowIsHeader(map, node, index); + } catch { + return false; + } +} + +/** + * Checks if a table row/column addition can be performed + * in the current editor state. + */ +function canAddRowColumn({ + editor, + index, + orientation, + tablePos, + side +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; + side: RowSide | ColSide; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType(editor, index, orientation); + if (!selectionType) return false; + + const { map, node } = table; + const selIndex = selectionType.index; + const selOrient = selectionType.orientation; + + // Bounds check + if (typeof selIndex !== 'number' || selIndex < 0) return false; + if (selOrient === 'column' && selIndex >= map.width) return false; + if (selOrient === 'row' && selIndex >= map.height) return false; + + // Block inserting to the LEFT of a header column + if (side === 'left' && selOrient === 'column') { + if (safeColumnIsHeader(map, node, selIndex)) return false; + } + + // Block inserting ABOVE a header row + if (side === 'above' && selOrient === 'row') { + if (safeRowIsHeader(map, node, selIndex)) return false; + } + + return true; +} + +/** + * Calculates the index of the newly added row or column. + */ +function calculateNewIndex( + index: number, + orientation: Orientation, + side: RowSide | ColSide +): number { + if (orientation === 'row') { + // For rows: above means the new row is at the same index (pushes original down) + // below means the new row is at index + 1 + return side === 'above' ? index : index + 1; + } else { + // For columns: left means the new column is at the same index (pushes original right) + // right means the new column is at index + 1 + return side === 'left' ? index : index + 1; + } +} + +/** + * Executes the row/column addition in the editor. + */ +function tableAddRowColumn({ + editor, + index, + orientation, + side, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + side: RowSide | ColSide; + tablePos: number | undefined; +}): boolean { + if ( + !canAddRowColumn({ editor, index, orientation, tablePos, side }) || + !editor + ) { + return false; + } + + const selectionType = getTableSelectionType(editor, index, orientation); + if (!selectionType) return false; + + const { orientation: finalOrientation, index: finalIndex } = selectionType; + + const isRow = finalOrientation === 'row'; + const dispatch = (tr: Transaction) => editor.view.dispatch(tr); + const addOperation = isRow + ? side === 'above' + ? addRowBefore + : addRowAfter + : side === 'left' + ? addColumnBefore + : addColumnAfter; + + try { + let success = false; + + if (editor.state.selection instanceof CellSelection) { + success = addOperation(editor.state, dispatch); + } else { + const table = getTable(editor, tablePos); + if (!table) return false; + + const cellCoords = + finalOrientation === 'row' + ? { row: finalIndex, col: 0 } + : { row: 0, col: finalIndex }; + + const cellState = selectCellsByCoords(editor, table.pos, [cellCoords], { + mode: 'state' + }); + + if (!cellState) return false; + + success = addOperation(cellState, dispatch); + } + + if (success) { + const newIndex = calculateNewIndex(finalIndex, finalOrientation, side); + updateSelectionAfterAction(editor, finalOrientation, newIndex, tablePos); + } + + return success; + } catch (error) { + console.error('Error adding row/column:', error); + return false; + } +} + +/** + * Determines if the add button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + index, + orientation, + tablePos, + side, + hideWhenUnavailable +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; + side: RowSide | ColSide; + hideWhenUnavailable: boolean; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + if (hideWhenUnavailable) { + return canAddRowColumn({ editor, index, orientation, tablePos, side }); + } + + const selectionType = getTableSelectionType(editor, index, orientation); + return Boolean(selectionType); +} + +/** + * Custom hook that provides **table row/column addition** + * functionality for the Tiptap editor. + */ +export function useTableAddRowColumn(config: UseTableAddRowColumnConfig) { + const { + editor: providedEditor, + index, + orientation, + side, + tablePos, + hideWhenUnavailable = false, + onAdded + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const selectionType = getTableSelectionType(editor, index, orientation); + + const isVisible = shouldShowButton({ + editor, + index, + orientation, + tablePos, + side, + hideWhenUnavailable + }); + + const canPerformAdd = canAddRowColumn({ + editor, + index, + orientation, + tablePos, + side + }); + + const handleAdd = useCallback(() => { + const success = tableAddRowColumn({ + editor, + index, + orientation, + tablePos, + side + }); + if (success) onAdded?.(); + return success; + }, [editor, index, orientation, tablePos, side, onAdded]); + + const label = + selectionType?.orientation === 'row' + ? tableAddRowColumnLabels.row[side as RowSide] + : tableAddRowColumnLabels.column[side as ColSide]; + + const Icon = + selectionType?.orientation === 'row' + ? side === 'above' + ? AddRowTopIcon + : AddRowBottomIcon + : side === 'left' + ? AddColLeftIcon + : AddColRightIcon; + + return { + isVisible, + canAddRowColumn: canPerformAdd, + handleAdd, + label, + Icon + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/index.tsx new file mode 100644 index 00000000..1a68a518 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-align-cell-button'; +export * from './use-table-align-cell'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/table-align-cell-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/table-align-cell-button.tsx new file mode 100644 index 00000000..b9e23694 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/table-align-cell-button.tsx @@ -0,0 +1,124 @@ +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableAlignCellConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-align-cell-button'; +import { useTableAlignCell } from '@workspace/editor/components/tiptap-node/table-node/ui/table-align-cell-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableAlignCellButtonProps + extends Omit, UseTableAlignCellConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for aligning table cells in a Tiptap editor. + * Supports both text alignment (left, center, right, justify) and + * vertical alignment (top, middle, bottom). + * + * Can align either the currently selected cell(s) or all cells in a specific row/column. + * + * @example + * ```tsx + * // Align the currently selected cell(s) + * + * + * // Align all cells in row 0 + * + * + * // Align all cells in column 2 + * + * ``` + */ +export const TableAlignCellButton = forwardRef< + HTMLButtonElement, + TableAlignCellButtonProps +>( + ( + { + editor: providedEditor, + alignmentType, + alignment, + index, + orientation, + hideWhenUnavailable = false, + onAligned, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleAlign, label, canAlignCell, Icon, isActive } = + useTableAlignCell({ + editor, + alignmentType, + alignment, + index, + orientation, + hideWhenUnavailable, + onAligned + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleAlign(); + }, + [handleAlign, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableAlignCellButton.displayName = 'TableAlignCellButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/use-table-align-cell.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/use-table-align-cell.ts new file mode 100644 index 00000000..97689c7b --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-align-cell-button/use-table-align-cell.ts @@ -0,0 +1,546 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import type { Editor } from '@tiptap/react'; + +import { AlignBottomIcon } from '@workspace/editor/components/tiptap-icons/align-bottom-icon'; +import { AlignCenterIcon } from '@workspace/editor/components/tiptap-icons/align-center-icon'; +import { AlignJustifyIcon } from '@workspace/editor/components/tiptap-icons/align-justify-icon'; +// --- Icons --- +import { AlignLeftIcon } from '@workspace/editor/components/tiptap-icons/align-left-icon'; +import { AlignMiddleIcon } from '@workspace/editor/components/tiptap-icons/align-middle-icon'; +import { AlignRightIcon } from '@workspace/editor/components/tiptap-icons/align-right-icon'; +import { AlignTopIcon } from '@workspace/editor/components/tiptap-icons/align-top-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + getRowOrColumnCells, + getTable +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export type TextAlignment = 'left' | 'center' | 'right' | 'justify'; +export type VerticalAlignment = 'top' | 'middle' | 'bottom'; +export type AlignmentType = 'text' | 'vertical'; + +export interface UseTableAlignCellConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The type of alignment to apply. + */ + alignmentType: AlignmentType; + /** + * The alignment value to set. + */ + alignment: TextAlignment | VerticalAlignment; + /** + * Optional index of the row or column to align. + * If provided along with orientation, aligns all cells in that row/column. + * If not provided, aligns the currently selected cell(s). + */ + index?: number; + /** + * Optional orientation when using index. + * Determines whether to align a row or column. + */ + orientation?: Orientation; + /** + * Hide the button when alignment isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after successful alignment change. + */ + onAligned?: (alignment: TextAlignment | VerticalAlignment) => void; +} + +const REQUIRED_EXTENSIONS = ['table']; + +export const tableAlignCellLabels = { + text: { + left: 'Align left', + center: 'Align center', + right: 'Align right', + justify: 'Justify' + } as Record, + vertical: { + top: 'Align top', + middle: 'Align middle', + bottom: 'Align bottom' + } as Record +}; + +export const tableAlignCellIcons = { + text: { + left: AlignLeftIcon, + center: AlignCenterIcon, + right: AlignRightIcon, + justify: AlignJustifyIcon + } as Record>, + vertical: { + top: AlignTopIcon, + middle: AlignMiddleIcon, + bottom: AlignBottomIcon + } as Record> +}; + +/** + * Checks if table cell alignment can be performed + * in the current editor state. + */ +function canAlignCell(editor: Editor | null): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + return editor.isActive('tableCell') || editor.isActive('tableHeader'); + } catch { + return false; + } +} + +/** + * Checks if row/column-wide alignment can be performed + * in the current editor state. + */ +function canAlignRowColumn({ + editor, + index, + orientation +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + const table = getTable(editor); + if (!table) return false; + + const cellData = getRowOrColumnCells(editor, index, orientation); + + if (cellData.cells.length === 0) return false; + + return true; + } catch { + return false; + } +} + +/** + * Gets the current alignment value for the active cell. + */ +function getCurrentAlignment( + editor: Editor | null, + alignmentType: AlignmentType +): TextAlignment | VerticalAlignment | null { + if (!canAlignCell(editor) || !editor) return null; + + try { + const { selection } = editor.state; + const $anchor = selection.$anchor; + + let cellNode = null; + for (let depth = $anchor.depth; depth >= 0; depth--) { + const node = $anchor.node(depth); + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + cellNode = node; + break; + } + } + + if (!cellNode) return null; + + const attrs = cellNode.attrs || {}; + + if (alignmentType === 'text') { + return (attrs.nodeTextAlign as TextAlignment) || 'left'; + } else { + return (attrs.nodeVerticalAlign as VerticalAlignment) || 'top'; + } + } catch { + return null; + } +} + +/** + * Gets the current alignment for a specific row or column. + */ +function getCurrentRowColumnAlignment( + editor: Editor | null, + alignmentType: AlignmentType, + index?: number, + orientation?: Orientation +): TextAlignment | VerticalAlignment | null { + if (!editor) return null; + + try { + const cellData = getRowOrColumnCells(editor, index, orientation); + + if (cellData.cells.length === 0) return null; + + const firstCell = cellData.cells[0]; + if (!firstCell?.node) return null; + + const attrs = firstCell.node.attrs || {}; + + if (alignmentType === 'text') { + return (attrs.nodeTextAlign as TextAlignment) || 'left'; + } else { + return (attrs.nodeVerticalAlign as VerticalAlignment) || 'top'; + } + } catch { + return null; + } +} + +/** + * Sets the alignment attribute on the current table cell. + */ +function setTableCellAlignment( + editor: Editor | null, + alignmentType: AlignmentType, + alignment: TextAlignment | VerticalAlignment +): boolean { + if (!canAlignCell(editor) || !editor) return false; + + try { + if (alignmentType === 'text') { + return editor.commands.setCellAttribute('nodeTextAlign', alignment); + } else { + return editor.commands.setCellAttribute('nodeVerticalAlign', alignment); + } + } catch (error) { + console.error('Error setting table cell alignment:', error); + return false; + } +} + +/** + * Sets alignment for all cells in a specific row or column. + */ +function setRowColumnAlignment({ + editor, + alignmentType, + alignment, + index, + orientation +}: { + editor: Editor | null; + alignmentType: AlignmentType; + alignment: TextAlignment | VerticalAlignment; + index?: number; + orientation?: Orientation; +}): boolean { + if (!canAlignRowColumn({ editor, index, orientation }) || !editor) { + return false; + } + + try { + const { state, view } = editor; + const tr = state.tr; + + const cellData = getRowOrColumnCells(editor, index, orientation); + + if (cellData.cells.length === 0) { + return false; + } + + // Track unique cells to avoid aligning the same merged cell multiple times + const uniqueCells = new Map(); + + cellData.cells.forEach((cellInfo) => { + if (cellInfo.node && cellInfo.pos !== undefined) { + uniqueCells.set(cellInfo.pos, cellInfo); + } + }); + + if (uniqueCells.size === 0) { + return false; + } + + // Convert to array and sort by position in reverse order + // This ensures we replace cells from end to beginning to maintain correct positions + const cellsToProcess = Array.from(uniqueCells.values()).sort( + (a, b) => b.pos - a.pos + ); + + const attributeName = + alignmentType === 'text' ? 'nodeTextAlign' : 'nodeVerticalAlign'; + + cellsToProcess.forEach((cellInfo) => { + if (cellInfo.node && cellInfo.pos !== undefined) { + const cellType = cellInfo.node.type; + + const newCellNode = cellType.create( + { + ...cellInfo.node.attrs, + [attributeName]: alignment + }, + cellInfo.node.content, + cellInfo.node.marks + ); + + const cellEnd = cellInfo.pos + cellInfo.node.nodeSize; + tr.replaceWith(cellInfo.pos, cellEnd, newCellNode); + } + }); + + if (tr.docChanged) { + view.dispatch(tr); + return true; + } + + return false; + } catch (error) { + console.error(`Error aligning table ${orientation}:`, error); + return false; + } +} + +/** + * Executes the cell alignment in the editor. + */ +function tableAlignCell({ + editor, + alignmentType, + alignment, + index, + orientation +}: { + editor: Editor | null; + alignmentType: AlignmentType; + alignment: TextAlignment | VerticalAlignment; + index?: number; + orientation?: Orientation; +}): boolean { + if (!editor) return false; + + try { + if (typeof index === 'number' && orientation) { + return setRowColumnAlignment({ + editor, + alignmentType, + alignment, + index, + orientation + }); + } else { + return setTableCellAlignment(editor, alignmentType, alignment); + } + } catch (error) { + console.error('Error aligning table cell:', error); + return false; + } +} + +/** + * Determines if the align cell button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + hideWhenUnavailable, + index, + orientation +}: { + editor: Editor | null; + hideWhenUnavailable: boolean; + index?: number; + orientation?: Orientation; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + if (hideWhenUnavailable) { + if (typeof index === 'number' && orientation) { + return canAlignRowColumn({ editor, index, orientation }); + } + + return canAlignCell(editor); + } + + return true; +} + +/** + * Custom hook that provides **table cell alignment** + * functionality for the Tiptap editor. + * + * @example + * ```tsx + * // Simple text alignment button + * function AlignLeftButton() { + * const { isVisible, handleAlign, canAlignCell, isActive, label, Icon } = useTableAlignCell({ + * alignmentType: "text", + * alignment: "left", + * hideWhenUnavailable: true, + * onAligned: (alignment) => console.log(`Aligned to: ${alignment}`), + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Align entire row vertically + * function AlignRowTopButton({ rowIndex }: { rowIndex: number }) { + * const { isVisible, handleAlign, label, Icon } = useTableAlignCell({ + * alignmentType: "vertical", + * alignment: "top", + * index: rowIndex, + * orientation: "row", + * hideWhenUnavailable: true, + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Alignment toolbar for selected cell + * function CellAlignmentToolbar() { + * const alignments: TextAlignment[] = ["left", "center", "right", "justify"] + * + * return ( + *
+ * {alignments.map((alignment) => { + * const { isVisible, handleAlign, canAlignCell, isActive, Icon } = useTableAlignCell({ + * alignmentType: "text", + * alignment, + * hideWhenUnavailable: true, + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * })} + *
+ * ) + * } + * ``` + */ +export function useTableAlignCell(config: UseTableAlignCellConfig) { + const { + editor: providedEditor, + alignmentType, + alignment, + index, + orientation, + hideWhenUnavailable = false, + onAligned + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const isVisible = shouldShowButton({ + editor, + hideWhenUnavailable, + index, + orientation + }); + + const canPerformAlign = () => { + if (typeof index === 'number' && orientation) { + return canAlignRowColumn({ editor, index, orientation }); + } + return canAlignCell(editor); + }; + + const currentAlignment = () => { + if (typeof index === 'number' && orientation) { + return getCurrentRowColumnAlignment( + editor, + alignmentType, + index, + orientation + ); + } + return getCurrentAlignment(editor, alignmentType); + }; + + const isActive = currentAlignment() === alignment; + + const handleAlign = useCallback(() => { + const success = tableAlignCell({ + editor, + alignmentType, + alignment, + index, + orientation + }); + + if (success) { + onAligned?.(alignment); + } + return success; + }, [editor, alignmentType, alignment, index, orientation, onAligned]); + + const label = useMemo(() => { + if (alignmentType === 'text') { + return tableAlignCellLabels.text[alignment as TextAlignment]; + } else { + return tableAlignCellLabels.vertical[alignment as VerticalAlignment]; + } + }, [alignmentType, alignment]); + + const Icon = useMemo(() => { + if (alignmentType === 'text') { + return tableAlignCellIcons.text[alignment as TextAlignment]; + } else { + return tableAlignCellIcons.vertical[alignment as VerticalAlignment]; + } + }, [alignmentType, alignment]); + + return { + isVisible, + canAlignCell: canPerformAlign, + handleAlign, + label, + Icon, + isActive, + currentAlignment + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-alignment-menu/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-alignment-menu/index.tsx new file mode 100644 index 00000000..050381f5 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-alignment-menu/index.tsx @@ -0,0 +1 @@ +export * from './table-alignment-menu'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-alignment-menu/table-alignment-menu.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-alignment-menu/table-alignment-menu.tsx new file mode 100644 index 00000000..5ed24abb --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-alignment-menu/table-alignment-menu.tsx @@ -0,0 +1,159 @@ +// --- Icons --- +import { AlignmentIcon } from '@workspace/editor/components/tiptap-icons/alignment-icon'; +import { ChevronRightIcon } from '@workspace/editor/components/tiptap-icons/chevron-right-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- UI --- +import { useTableAlignCell } from '@workspace/editor/components/tiptap-node/table-node/ui/table-align-cell-button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { ComboboxList } from '@workspace/editor/components/tiptap-ui-primitive/combobox'; +// --- UI Primitives --- +import { + Menu, + MenuButton, + MenuButtonArrow, + MenuContent, + MenuGroup, + MenuItem +} from '@workspace/editor/components/tiptap-ui-primitive/menu'; +import { Separator } from '@workspace/editor/components/tiptap-ui-primitive/separator'; + +export interface ActionItemProps { + icon: React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + label: string; + onClick: () => void; + disabled?: boolean; + isActive?: boolean; + shortcutBadge?: React.ReactNode; +} + +export const TableAlignMenu = ({ + index, + orientation +}: { + index?: number; + orientation?: Orientation; +}) => { + const textAlign = { + left: useTableAlignCell({ + alignmentType: 'text', + alignment: 'left', + index, + orientation + }), + center: useTableAlignCell({ + alignmentType: 'text', + alignment: 'center', + index, + orientation + }), + right: useTableAlignCell({ + alignmentType: 'text', + alignment: 'right', + index, + orientation + }) + }; + + const verticalAlign = { + top: useTableAlignCell({ + alignmentType: 'vertical', + alignment: 'top', + index, + orientation + }), + middle: useTableAlignCell({ + alignmentType: 'vertical', + alignment: 'middle', + index, + orientation + }), + bottom: useTableAlignCell({ + alignmentType: 'vertical', + alignment: 'bottom', + index, + orientation + }) + }; + + if (!textAlign.left.canAlignCell()) { + return null; + } + + return ( + + + Alignment + } /> + + } + /> + } + /> + } + > + + + + {Object.values(textAlign).map((align, i) => ( + + ))} + + {Object.values(verticalAlign).map((align, i) => ( + + ))} + + + + + ); +}; + +const ActionItem = ({ + icon: Icon, + label, + onClick, + disabled = false, + isActive = false, + shortcutBadge +}: ActionItemProps) => ( + + } + onClick={onClick} + disabled={disabled} + > + + {label} + {shortcutBadge} + +); + +ActionItem.displayName = 'ActionItem'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/index.tsx new file mode 100644 index 00000000..52c5ae12 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/index.tsx @@ -0,0 +1 @@ +export * from './table-cell-handle-menu'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/table-cell-handle-menu.scss b/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/table-cell-handle-menu.scss new file mode 100644 index 00000000..37d4d5ff --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/table-cell-handle-menu.scss @@ -0,0 +1,121 @@ +.expandable-menu-button { + --button-size: 16px; + --button-offset: -8px; + --dot-size-small: 8px; + --dot-size-large: 16px; + --dot-offset-small: 3px; + --dot-offset-large: 0; + --border-radius-full: 9999px; + --transition-duration: 0.05s; + --transition-timing: ease; + --icon-scale-small: 0.9; + --icon-scale-large: 1; + + position: absolute; + top: 50%; + right: var(--button-offset); + transform: translateY(-50%); + + width: var(--button-size); + height: var(--button-size); + + background: transparent; + border-radius: var(--border-radius-full); + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + pointer-events: auto; + + &::before { + content: ""; + position: absolute; + top: 50%; + right: var(--dot-offset-small); + + width: var(--dot-size-small); + height: var(--dot-size-small); + + background: var(--tt-brand-color-600); + border-radius: var(--border-radius-full); + transform: translateY(-50%); + + transition: + width var(--transition-duration) var(--transition-timing), + height var(--transition-duration) var(--transition-timing), + right var(--transition-duration) var(--transition-timing), + background var(--transition-duration) var(--transition-timing); + } + + svg { + position: relative; + z-index: 1; + + width: var(--button-size); + height: var(--button-size); + color: var(--white); + + opacity: 0; + transform: scale(var(--icon-scale-small)); + + transition: + opacity var(--transition-duration) var(--transition-timing), + transform var(--transition-duration) var(--transition-timing); + + pointer-events: none; + flex-shrink: 0; + } + + @media (hover: none) and (pointer: coarse) { + &::before { + width: var(--dot-size-large); + height: var(--dot-size-large); + right: var(--dot-offset-large); + } + + svg { + opacity: 1; + transform: scale(var(--icon-scale-large)); + } + } + + &:hover, + &:focus-visible, + &.menu-opened { + @media (hover: hover) { + &::before { + width: var(--dot-size-large); + height: var(--dot-size-large); + right: var(--dot-offset-large); + } + + svg { + opacity: 1; + transform: scale(var(--icon-scale-large)); + } + } + } + + &:focus-visible { + outline: 2px solid var(--tt-brand-color-600); + outline-offset: 2px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + + &::before { + background: var(--tt-disabled-color, #ccc); + } + } + + @media (prefers-reduced-motion: reduce) { + &::before, + svg { + transition: none; + } + } +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/table-cell-handle-menu.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/table-cell-handle-menu.tsx new file mode 100644 index 00000000..34f39f23 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-cell-handle-menu/table-cell-handle-menu.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { forwardRef, useCallback, useEffect, useState } from 'react'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { Grip4Icon } from '@workspace/editor/components/tiptap-icons/grip-4-icon'; +import { TableAlignMenu } from '@workspace/editor/components/tiptap-node/table-node/ui/table-alignment-menu'; +import { useTableClearRowColumnContent } from '@workspace/editor/components/tiptap-node/table-node/ui/table-clear-row-column-content-button'; +import { useTableMergeSplitCell } from '@workspace/editor/components/tiptap-node/table-node/ui/table-merge-split-cell-button'; +// --- UI Primitives --- +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { + Combobox, + ComboboxList +} from '@workspace/editor/components/tiptap-ui-primitive/combobox'; +import { + Menu, + MenuButton, + MenuContent, + MenuGroup, + MenuItem +} from '@workspace/editor/components/tiptap-ui-primitive/menu'; +import { Separator } from '@workspace/editor/components/tiptap-ui-primitive/separator'; +// --- UI --- +import { ColorMenu } from '@workspace/editor/components/tiptap-ui/color-menu'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { cn, SR_ONLY } from '@workspace/editor/lib/tiptap-utils'; + +import './table-cell-handle-menu.scss'; + +interface TableAction { + icon: React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + label: string; + onClick: () => void; + isAvailable: boolean; + isActive?: boolean; + shortcutBadge?: React.ReactNode; +} + +/** + * Hook to manage all table actions and their availability + */ +function useTableActions() { + const mergeCellAction = useTableMergeSplitCell({ action: 'merge' }); + const splitCellAction = useTableMergeSplitCell({ action: 'split' }); + const clearContentAction = useTableClearRowColumnContent({ + resetAttrs: true + }); + + const mergeAction: TableAction = { + icon: mergeCellAction.Icon, + label: mergeCellAction.label, + onClick: mergeCellAction.handleExecute, + isAvailable: mergeCellAction.canExecute + }; + + const splitAction: TableAction = { + icon: splitCellAction.Icon, + label: splitCellAction.label, + onClick: splitCellAction.handleExecute, + isAvailable: splitCellAction.canExecute + }; + + const clearAction: TableAction = { + icon: clearContentAction.Icon, + label: clearContentAction.label, + onClick: clearContentAction.handleClear, + isAvailable: clearContentAction.canClearRowColumnContent + }; + + return { + mergeAction, + splitAction, + clearAction + }; +} + +/** + * Hook to manage table handle menu state and interactions + */ +function useTableCellHandleMenu({ editor }: { editor: Editor | null }) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const closeMenu = useCallback(() => { + setIsMenuOpen(false); + editor?.commands.unfreezeHandles(); + }, [editor]); + + const handleMenuToggle = useCallback( + (isOpen: boolean) => { + setIsMenuOpen(isOpen); + + if (!editor) return; + + if (isOpen) { + editor.commands.freezeHandles(); + } else { + editor.commands.unfreezeHandles(); + } + }, + [editor] + ); + + return { + isMenuOpen, + handleMenuToggle, + closeMenu + }; +} + +const TableActionItem = ({ action }: { action: TableAction }) => { + const { + icon: Icon, + label, + onClick, + isActive = false, + shortcutBadge + } = action; + + return ( + + } + onClick={onClick} + > + + {label} + {shortcutBadge} + + ); +}; + +const TableActionMenu = ({ onClose }: { onClose: () => void }) => { + const { mergeAction, splitAction, clearAction } = useTableActions(); + + const hasMergeOrSplit = mergeAction.isAvailable || splitAction.isAvailable; + + return ( + + + + {hasMergeOrSplit && ( + + {mergeAction.isAvailable && ( + + )} + {splitAction.isAvailable && ( + + )} + + + )} + + + + + {clearAction.isAvailable && } + + + + ); +}; + +interface TableCellHandleMenuProps extends React.ComponentPropsWithoutRef<'button'> { + editor?: Editor | null; + onOpenChange?: (isOpen: boolean) => void; +} + +export const TableCellHandleMenu = forwardRef< + HTMLButtonElement, + TableCellHandleMenuProps +>(({ editor: providedEditor, onOpenChange, className, ...props }, ref) => { + const { editor } = useTiptapEditor(providedEditor); + const { isMenuOpen, handleMenuToggle, closeMenu } = useTableCellHandleMenu({ + editor + }); + + useEffect(() => { + onOpenChange?.(isMenuOpen); + }, [isMenuOpen, onOpenChange]); + + return ( + + + + } + > + + + ); +}); + +TableCellHandleMenu.displayName = 'TableCellHandleMenu'; + +export { TableActionMenu }; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/index.tsx new file mode 100644 index 00000000..94f511d4 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-clear-row-column-content-button'; +export * from './use-table-clear-row-column-content'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/table-clear-row-column-content-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/table-clear-row-column-content-button.tsx new file mode 100644 index 00000000..fc760f04 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/table-clear-row-column-content-button.tsx @@ -0,0 +1,96 @@ +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableClearRowColumnContentConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-clear-row-column-content-button'; +import { useTableClearRowColumnContent } from '@workspace/editor/components/tiptap-node/table-node/ui/table-clear-row-column-content-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableClearRowColumnContentButtonProps + extends Omit, UseTableClearRowColumnContentConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for clearing table row/column content in a Tiptap editor. + * + * For custom button implementations, use the `useTableClearRowColumnContent` hook instead. + */ +export const TableClearRowColumnContentButton = forwardRef< + HTMLButtonElement, + TableClearRowColumnContentButtonProps +>( + ( + { + editor: providedEditor, + index, + orientation, + hideWhenUnavailable = false, + resetAttrs = false, + onCleared, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleClear, label, canClearRowColumnContent, Icon } = + useTableClearRowColumnContent({ + editor, + index, + orientation, + hideWhenUnavailable, + resetAttrs, + onCleared + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleClear(); + }, + [handleClear, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableClearRowColumnContentButton.displayName = + 'TableClearRowColumnContentButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/use-table-clear-row-column-content.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/use-table-clear-row-column-content.ts new file mode 100644 index 00000000..17bce125 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-clear-row-column-content-button/use-table-clear-row-column-content.ts @@ -0,0 +1,468 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { + cellAround, + CellSelection, + deleteCellSelection +} from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { SquareXIcon } from '@workspace/editor/components/tiptap-icons/square-x-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + getRowOrColumnCells, + getTable, + getTableSelectionType, + isCellEmpty, + setCellAttr +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export interface UseTableClearRowColumnContentConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The index of the row or column to clear. + * If omitted, will clear the currently selected cells. + */ + index?: number; + /** + * Whether you're clearing a row or a column. + * If omitted, will clear the currently selected cells. + */ + orientation?: Orientation; + /** + * The position of the table in the document. + */ + tablePos?: number; + /** + * Hide the button when clearing isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Whether to reset cell attributes (backgroundColor, nodeVerticalAlign, nodeTextAlign) when clearing. + * @default false + */ + resetAttrs?: boolean; + /** + * Callback function called after a successful clear. + */ + onCleared?: () => void; +} + +const REQUIRED_EXTENSIONS = ['table']; + +export const tableClearRowColumnContentLabels: Record = { + row: 'Clear row contents', + column: 'Clear column contents' +}; + +/** + * Default cell attributes to reset when clearing content + */ +const DEFAULT_CELL_ATTRS = { + backgroundColor: null, + nodeVerticalAlign: null, + nodeTextAlign: null +}; + +/** + * Resets cell attributes to default values + */ +function resetCellAttributes(editor: Editor): boolean { + try { + return setCellAttr(DEFAULT_CELL_ATTRS)(editor.state, editor.view.dispatch); + } catch (error) { + console.error('Error resetting cell attributes:', error); + return false; + } +} + +/** + * Checks if a table row/column content clearing can be performed + * in the current editor state. + */ +function canClearRowColumnContent({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + + if (selectionType) { + const cellData = getRowOrColumnCells( + editor, + selectionType.index, + selectionType.orientation, + tablePos + ); + if (cellData.cells.length === 0) return false; + + return cellData.cells.some( + (cellInfo) => cellInfo.node && !isCellEmpty(cellInfo.node) + ); + } else { + const { selection } = editor.state; + + if (selection instanceof CellSelection) { + let hasContent = false; + selection.forEachCell((cell) => { + if (!isCellEmpty(cell)) { + hasContent = true; + } + }); + return hasContent; + } + + // Single cell case + const { $anchor } = selection; + const cell = cellAround($anchor); + if (!cell) return false; + + const cellNode = editor.state.doc.nodeAt(cell.pos); + return cellNode ? !isCellEmpty(cellNode) : false; + } + } catch { + return false; + } +} + +/** + * Clears content from selected cells and optionally resets attributes. + */ +function clearSelectedCells( + editor: Editor, + resetAttrs: boolean = false +): boolean { + try { + const { selection } = editor.state; + + if (selection instanceof CellSelection) { + if (resetAttrs) { + resetCellAttributes(editor); + } + + deleteCellSelection(editor.state, editor.view.dispatch); + + return true; + } + + // Handle single cell + const { $anchor } = selection; + const cell = cellAround($anchor); + if (!cell) return false; + + const cellNode = editor.state.doc.nodeAt(cell.pos); + if (!cellNode) return false; + + const from = cell.pos + 1; + const to = cell.pos + cellNode.nodeSize - 1; + if (from >= to) return false; + + if (resetAttrs) { + resetCellAttributes(editor); + } + + editor.view.dispatch(editor.state.tr.delete(from, to)); + + return true; + } catch (error) { + console.error('Error clearing selected cells:', error); + return false; + } +} + +/** + * Clears content from all cells in a specific row or column and optionally resets attributes. + */ +function clearRowColumnCells({ + editor, + index, + orientation, + tablePos, + resetAttrs = false +}: { + editor: Editor; + index: number; + orientation: Orientation; + tablePos?: number; + resetAttrs?: boolean; +}): boolean { + try { + const { state, view } = editor; + const tr = state.tr; + + const cellData = getRowOrColumnCells(editor, index, orientation, tablePos); + + if (cellData.cells.length === 0) { + return false; + } + + const cellsToProcess = [...cellData.cells].reverse(); + + cellsToProcess.forEach((cellInfo) => { + if (cellInfo.node && !isCellEmpty(cellInfo.node)) { + const from = cellInfo.pos + 1; + const to = cellInfo.pos + cellInfo.node.nodeSize - 1; + if (from < to) { + tr.delete(from, to); + } + + if (resetAttrs) { + tr.setNodeMarkup(cellInfo.pos, null, { + ...cellInfo.node.attrs, + ...DEFAULT_CELL_ATTRS + }); + } + } + }); + + if (tr.docChanged) { + view.dispatch(tr); + return true; + } + + return false; + } catch (error) { + console.error(`Error clearing ${orientation} content:`, error); + return false; + } +} + +/** + * Executes the row/column content clearing in the editor. + */ +function tableClearRowColumnContent({ + editor, + index, + orientation, + tablePos, + resetAttrs = false +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; + resetAttrs?: boolean; +}): boolean { + if ( + !canClearRowColumnContent({ editor, index, orientation, tablePos }) || + !editor + ) { + return false; + } + + try { + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + + if (selectionType) { + return clearRowColumnCells({ + editor, + index: selectionType.index, + orientation: selectionType.orientation, + resetAttrs, + tablePos + }); + } else { + return clearSelectedCells(editor, resetAttrs); + } + } catch (error) { + console.error('Error clearing table content:', error); + return false; + } +} + +/** + * Determines if the clear button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + index, + orientation, + tablePos, + hideWhenUnavailable +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; + hideWhenUnavailable: boolean; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + const { selection } = editor.state; + const isInTableCell = + selection instanceof CellSelection || cellAround(selection.$anchor); + + if (!selectionType && !isInTableCell) return false; + + return hideWhenUnavailable + ? canClearRowColumnContent({ editor, index, orientation, tablePos }) + : true; +} + +/** + * Custom hook that provides **table row/column content clearing** + * functionality for the Tiptap editor. + * + * @example + * ```tsx + * // Clear currently selected cells (no parameters needed) + * function ClearSelectedButton() { + * const { isVisible, handleClear } = useTableClearRowColumnContent() + * + * if (!isVisible) return null + * + * return + * } + * + * // Clear specific row with attribute reset + * function ClearRowButton({ rowIndex }: { rowIndex: number }) { + * const { isVisible, handleClear, label, canClearRowColumnContent } = useTableClearRowColumnContent({ + * index: rowIndex, + * orientation: "row", + * resetAttrs: true, + * hideWhenUnavailable: true, + * onCleared: () => console.log("Row cleared and attributes reset!"), + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Clear content based on current table selection (row/column/cells) + * function SmartClearButton() { + * const { isVisible, handleClear, label } = useTableClearRowColumnContent({ + * resetAttrs: true, + * hideWhenUnavailable: true + * }) + * + * if (!isVisible) return null + * + * return + * } + * ``` + */ +export function useTableClearRowColumnContent( + config: UseTableClearRowColumnContentConfig = {} +) { + const { + editor: providedEditor, + index, + orientation, + tablePos, + hideWhenUnavailable = false, + resetAttrs = false, + onCleared + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + + const isVisible = shouldShowButton({ + editor, + index, + orientation, + tablePos, + hideWhenUnavailable + }); + + const canPerformClear = canClearRowColumnContent({ + editor, + index, + orientation, + tablePos + }); + + const handleClear = useCallback(() => { + const success = tableClearRowColumnContent({ + editor, + index, + orientation, + tablePos, + resetAttrs + }); + if (success) onCleared?.(); + return success; + }, [editor, index, orientation, tablePos, resetAttrs, onCleared]); + + const label = useMemo(() => { + if (selectionType) { + return tableClearRowColumnContentLabels[selectionType.orientation]; + } + return 'Clear contents'; + }, [selectionType]); + + const Icon = SquareXIcon; + + return { + isVisible, + canClearRowColumnContent: canPerformClear, + handleClear, + label, + Icon + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/index.tsx new file mode 100644 index 00000000..69ad7674 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-delete-row-column-button'; +export * from './use-table-delete-row-column'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/table-delete-row-column-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/table-delete-row-column-button.tsx new file mode 100644 index 00000000..6206241f --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/table-delete-row-column-button.tsx @@ -0,0 +1,95 @@ +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableDeleteRowColumnConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-delete-row-column-button'; +import { useTableDeleteRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-delete-row-column-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableDeleteRowColumnButtonProps + extends Omit, UseTableDeleteRowColumnConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for deleting a table row/column in a Tiptap editor. + * + * For custom button implementations, use the `useTableDeleteRowColumn` hook instead. + */ +export const TableDeleteRowColumnButton = forwardRef< + HTMLButtonElement, + TableDeleteRowColumnButtonProps +>( + ( + { + editor: providedEditor, + index, + orientation, + tablePos, + hideWhenUnavailable = false, + onDeleted, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleDelete, label, canDeleteRowColumn, Icon } = + useTableDeleteRowColumn({ + editor, + index, + orientation, + tablePos, + hideWhenUnavailable, + onDeleted + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleDelete(); + }, + [handleDelete, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableDeleteRowColumnButton.displayName = 'TableDeleteRowColumnButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/use-table-delete-row-column.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/use-table-delete-row-column.ts new file mode 100644 index 00000000..c7aa1fcd --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-delete-row-column-button/use-table-delete-row-column.ts @@ -0,0 +1,264 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import type { Transaction } from '@tiptap/pm/state'; +import { CellSelection, deleteColumn, deleteRow } from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { TrashIcon } from '@workspace/editor/components/tiptap-icons/trash-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + getTable, + getTableSelectionType, + selectCellsByCoords +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export interface UseTableDeleteRowColumnConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The index of the row or column to delete. + */ + index?: number; + /** + * Whether you're deleting a row or a column. + */ + orientation?: Orientation; + /** + * The position of the table in the document. + */ + tablePos?: number; + /** + * Hide the button when deletion isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after a successful delete. + */ + onDeleted?: () => void; +} + +const REQUIRED_EXTENSIONS = ['table']; + +export const tableDeleteRowColumnLabels: Record = { + row: 'Delete row', + column: 'Delete column' +}; + +/** + * Checks if a table row/column delete can be performed + * in the current editor state. + */ +function canDeleteRowColumn({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType(editor, index, orientation); + if (!selectionType) return false; + + return true; + } catch { + return false; + } +} + +/** + * Executes the row/column deletion in the editor. + */ +function tableDeleteRowColumn({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if ( + !canDeleteRowColumn({ editor, index, orientation, tablePos }) || + !editor + ) { + return false; + } + + try { + const selectionType = getTableSelectionType(editor, index, orientation); + if (!selectionType) return false; + + const { orientation: finalOrientation, index: finalIndex } = selectionType; + + const isRow = finalOrientation === 'row'; + const dispatch = (tr: Transaction) => editor.view.dispatch(tr); + const deleteOperation = isRow ? deleteRow : deleteColumn; + + if (editor.state.selection instanceof CellSelection) { + return deleteOperation(editor.state, dispatch); + } + + const table = getTable(editor, tablePos); + if (!table) return false; + + const cellCoords = isRow + ? { row: finalIndex, col: 0 } + : { row: 0, col: finalIndex }; + + const cellState = selectCellsByCoords(editor, table.pos, [cellCoords], { + mode: 'state' + }); + + if (!cellState) return false; + + return deleteOperation(cellState, dispatch); + } catch (error) { + console.error(`Error deleting table row/column:`, error); + return false; + } +} + +/** + * Determines if the delete button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + hideWhenUnavailable: boolean; + tablePos?: number; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + return hideWhenUnavailable + ? canDeleteRowColumn({ editor, index, orientation, tablePos }) + : true; +} + +/** + * Custom hook that provides **table row/column deletion** + * functionality for the Tiptap editor. + * + * @example + * ```tsx + * // Simple usage with default editor context + * function DeleteRowButton() { + * const { isVisible, handleDelete } = useTableDeleteRowColumn({ + * index: 0, + * orientation: "row", + * }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with custom editor instance + * function DeleteColumnButton({ editor }: { editor: Editor }) { + * const { isVisible, handleDelete, label, canDeleteRowColumn, Icon } = useTableDeleteRowColumn({ + * editor, + * index: 1, + * orientation: "column", + * hideWhenUnavailable: true, + * onDeleted: () => console.log("Column deleted!"), + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * ``` + */ +export function useTableDeleteRowColumn(config: UseTableDeleteRowColumnConfig) { + const { + editor: providedEditor, + index, + orientation, + tablePos, + hideWhenUnavailable = false, + onDeleted + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const selectionType = getTableSelectionType(editor, index, orientation); + + const isVisible = shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable + }); + + const canPerformDelete = canDeleteRowColumn({ + editor, + index, + orientation, + tablePos + }); + + const handleDelete = useCallback(() => { + const success = tableDeleteRowColumn({ + editor, + index, + orientation, + tablePos + }); + if (success) onDeleted?.(); + return success; + }, [editor, index, orientation, tablePos, onDeleted]); + + const label = useMemo(() => { + return tableDeleteRowColumnLabels[selectionType?.orientation || 'row']; + }, [selectionType]); + + return { + isVisible, + canDeleteRowColumn: canPerformDelete, + handleDelete, + label, + Icon: TrashIcon + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/index.tsx new file mode 100644 index 00000000..5774a2dd --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-duplicate-row-column-button'; +export * from './use-table-duplicate-row-column'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/table-duplicate-row-column-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/table-duplicate-row-column-button.tsx new file mode 100644 index 00000000..8825d51a --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/table-duplicate-row-column-button.tsx @@ -0,0 +1,87 @@ +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableDuplicateRowColumnConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-duplicate-row-column-button'; +import { useTableDuplicateRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-duplicate-row-column-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableDuplicateRowColumnButtonProps + extends Omit, UseTableDuplicateRowColumnConfig { + text?: string; +} + +export const TableDuplicateRowColumnButton = forwardRef< + HTMLButtonElement, + TableDuplicateRowColumnButtonProps +>( + ( + { + editor: providedEditor, + index, + orientation, + tablePos, + hideWhenUnavailable = false, + onDuplicated, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleDuplicate, label, canDuplicateRowColumn, Icon } = + useTableDuplicateRowColumn({ + editor, + index, + orientation, + tablePos, + hideWhenUnavailable, + onDuplicated + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleDuplicate(); + }, + [handleDuplicate, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableDuplicateRowColumnButton.displayName = 'TableDuplicateRowColumnButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/use-table-duplicate-row-column.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/use-table-duplicate-row-column.ts new file mode 100644 index 00000000..35c04dc6 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-duplicate-row-column-button/use-table-duplicate-row-column.ts @@ -0,0 +1,410 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { addColumnAfter, addRowAfter, CellSelection } from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { CopyIcon } from '@workspace/editor/components/tiptap-icons/copy-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + getIndexCoordinates, + getRowOrColumnCells, + getTable, + getTableSelectionType, + selectCellsByCoords, + updateSelectionAfterAction +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export interface UseTableDuplicateRowColumnConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The index of the row or column to duplicate. + */ + index?: number; + /** + * Whether you're duplicating a row or a column. + */ + orientation?: Orientation; + /** + * The position of the table in the document. + */ + tablePos?: number; + /** + * Hide the button when duplication isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after a successful duplication. + */ + onDuplicated?: () => void; +} + +const REQUIRED_EXTENSIONS = ['tableHandleExtension']; + +export const tableDuplicateRowColumnLabels: Record = { + row: 'Duplicate row', + column: 'Duplicate column' +}; + +/** + * Checks if a table row/column duplication can be performed + * in the current editor state. + */ +function canDuplicateRowColumn({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + const table = getTable(editor, tablePos); + if (!table) return false; + + const cellData = getRowOrColumnCells(editor, index, orientation, tablePos); + + if (cellData.mergedCells.length > 0) { + return false; + } + + return cellData.cells.length > 0; + } catch { + return false; + } +} + +/** + * Duplicates a row by using addRowAfter and then replacing the content. + */ +function duplicateRow({ + editor, + index, + tablePos +}: { + editor: Editor; + index: number; + tablePos?: number; +}): boolean { + try { + const originalRowCells = getRowOrColumnCells( + editor, + index, + 'row', + tablePos + ); + + if (originalRowCells.cells.length === 0) { + return false; + } + + let addSuccess = false; + if (editor.state.selection instanceof CellSelection) { + addSuccess = editor.chain().focus().addRowAfter().run(); + } else { + if (!tablePos) return false; + const sourceCoords = getIndexCoordinates({ + editor, + index, + orientation: 'row', + tablePos + }); + if (!sourceCoords) return false; + + const state = selectCellsByCoords(editor, tablePos, sourceCoords, { + mode: 'state' + }); + addSuccess = addRowAfter(state, editor.view.dispatch); + } + + if (!addSuccess) return false; + + const newRowCells = getRowOrColumnCells(editor, index + 1, 'row', tablePos); + + if (newRowCells.cells.length === 0) { + return false; + } + + const { state, view } = editor; + const tr = state.tr; + + // Replace each cell in the new row with duplicated content + // Process in reverse order to maintain correct positions + const cellsToReplace = [...newRowCells.cells].reverse(); + const originalCells = [...originalRowCells.cells].reverse(); + + cellsToReplace.forEach((newCell, reverseIndex) => { + const originalCell = originalCells[reverseIndex]; + if (newCell.node && originalCell?.node) { + const duplicatedCell = newCell.node.type.create( + { ...originalCell.node.attrs }, + originalCell.node.content, + originalCell.node.marks + ); + + const cellEnd = newCell.pos + newCell.node.nodeSize; + tr.replaceWith(newCell.pos, cellEnd, duplicatedCell); + } + }); + + if (tr.docChanged) { + view.dispatch(tr); + + updateSelectionAfterAction(editor, 'row', index + 1, tablePos); + + return true; + } + + return false; + } catch (error) { + console.error('Error duplicating row:', error); + return false; + } +} + +/** + * Duplicates a column by using addColumnAfter and then replacing the content. + */ +function duplicateColumn({ + editor, + index, + tablePos +}: { + editor: Editor; + index: number; + tablePos?: number; +}): boolean { + try { + const originalColumnCells = getRowOrColumnCells( + editor, + index, + 'column', + tablePos + ); + if (originalColumnCells.cells.length === 0) return false; + + let addSuccess = false; + if (editor.state.selection instanceof CellSelection) { + addSuccess = editor.chain().focus().addColumnAfter().run(); + } else { + if (!tablePos) return false; + const sourceCoords = getIndexCoordinates({ + editor, + index, + orientation: 'column', + tablePos + }); + if (!sourceCoords) return false; + + const state = selectCellsByCoords(editor, tablePos, sourceCoords, { + mode: 'state' + }); + addSuccess = addColumnAfter(state, editor.view.dispatch); + } + + if (!addSuccess) return false; + + const newColumnCells = getRowOrColumnCells( + editor, + index + 1, + 'column', + tablePos + ); + + if (newColumnCells.cells.length === 0) { + return false; + } + + const { state, view } = editor; + const tr = state.tr; + + // Replace each cell in the new column with duplicated content + // Process in reverse order to maintain correct positions + const cellsToReplace = [...newColumnCells.cells].reverse(); + const originalCells = [...originalColumnCells.cells].reverse(); + + cellsToReplace.forEach((newCell, reverseIndex) => { + const originalCell = originalCells[reverseIndex]; + if (newCell.node && originalCell?.node) { + const duplicatedCell = newCell.node.type.create( + { ...originalCell.node.attrs }, + originalCell.node.content, + originalCell.node.marks + ); + + const cellEnd = newCell.pos + newCell.node.nodeSize; + tr.replaceWith(newCell.pos, cellEnd, duplicatedCell); + } + }); + + if (tr.docChanged) { + view.dispatch(tr); + + updateSelectionAfterAction(editor, 'column', index + 1, tablePos); + + return true; + } + + return false; + } catch (error) { + console.error('Error duplicating column:', error); + return false; + } +} + +/** + * Executes the row/column duplication in the editor. + */ +function tableDuplicateRowColumn({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if ( + !canDuplicateRowColumn({ editor, index, orientation, tablePos }) || + !editor + ) { + return false; + } + + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + if (!selectionType) return false; + + try { + if (selectionType.orientation === 'row') { + return duplicateRow({ + editor, + index: selectionType.index, + tablePos + }); + } else if (selectionType.orientation === 'column') { + return duplicateColumn({ + editor, + index: selectionType.index, + tablePos + }); + } + + return false; + } catch (error) { + console.error('Error duplicating row/column:', error); + return false; + } +} + +/** + * Determines if the duplicate button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + hideWhenUnavailable: boolean; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + return hideWhenUnavailable + ? canDuplicateRowColumn({ editor, index, orientation }) + : true; +} + +/** + * Custom hook that provides **table row/column duplication** + * functionality for the Tiptap editor. + */ +export function useTableDuplicateRowColumn( + config: UseTableDuplicateRowColumnConfig +) { + const { + editor: providedEditor, + index, + orientation, + tablePos, + hideWhenUnavailable = false, + onDuplicated + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const selectionType = getTableSelectionType(editor, index, orientation); + + const isVisible = shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable + }); + + const canPerformDuplicate = canDuplicateRowColumn({ + editor, + index, + tablePos, + orientation + }); + + const handleDuplicate = useCallback(() => { + const success = tableDuplicateRowColumn({ + editor, + index, + orientation, + tablePos + }); + if (success) onDuplicated?.(); + return success; + }, [editor, index, orientation, tablePos, onDuplicated]); + + const label = useMemo(() => { + return tableDuplicateRowColumnLabels[selectionType?.orientation || 'row']; + }, [selectionType]); + + const Icon = CopyIcon; + + return { + isVisible, + canDuplicateRowColumn: canPerformDuplicate, + handleDuplicate, + label, + Icon + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/index.tsx new file mode 100644 index 00000000..9b23e8d1 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-extend-row-column-button'; +export * from './use-table-extend-row-column'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/table-extend-row-column-button.scss b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/table-extend-row-column-button.scss new file mode 100644 index 00000000..6c057d9b --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/table-extend-row-column-button.scss @@ -0,0 +1,36 @@ +:root { + --tt-table-handle-bg-color: var(--tt-gray-light-a-100); + --tt-table-extend-icon-color: var(--tt-gray-light-a-400); +} + +.dark { + --tt-table-handle-bg-color: var(--tt-gray-dark-a-100); + --tt-table-extend-icon-color: var(--tt-gray-dark-a-400); +} + +.tiptap-table-extend-row-column-button { + border: none; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--tt-table-handle-bg-color); + border-radius: var(--tt-radius-lg); + + .tiptap-button-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--tt-table-extend-icon-color); + } + + &.tiptap-table-row-end-add-remove { + width: 100%; + height: 0.75rem; + cursor: row-resize; + } + + &.tiptap-table-column-end-add-remove { + width: 0.75rem; + cursor: col-resize; + } +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/table-extend-row-column-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/table-extend-row-column-button.tsx new file mode 100644 index 00000000..c5595afe --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/table-extend-row-column-button.tsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FloatingPortal } from '@floating-ui/react'; +import type { Node } from '@tiptap/pm/model'; +import { TableMap } from '@tiptap/pm/tables'; +import { type Editor } from '@tiptap/react'; + +// --- Icons --- +import { PlusSmallIcon } from '@workspace/editor/components/tiptap-icons/plus-small-icon'; +import { useTableHandleState } from '@workspace/editor/components/tiptap-node/table-node/hooks/use-table-handle-state'; +// --- Lib --- +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + countEmptyColumnsFromEnd, + countEmptyRowsFromEnd, + EMPTY_CELL_HEIGHT, + EMPTY_CELL_WIDTH, + marginRound, + runPreservingCursor, + selectLastCell +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Internal --- +import { useTableExtendRowColumnButtonsPositioning } from '@workspace/editor/components/tiptap-node/table-node/ui/table-extend-row-column-button/use-table-extend-row-column'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +import './table-extend-row-column-button.scss'; + +interface TableExtendRowColumnButtonProps { + editor?: Editor | null; + block: Node; + onMouseDown: () => void; + onMouseUp: () => void; + orientation: Orientation; + children?: React.ReactNode; +} + +/** + * Simplified button component for extending/reducing table dimensions + */ +export const TableExtendRowColumnButton: React.FC< + TableExtendRowColumnButtonProps +> = ({ + editor: providedEditor, + onMouseDown, + onMouseUp, + orientation, + children +}) => { + const { editor } = useTiptapEditor(providedEditor); + const state = useTableHandleState({ editor }); + const isRowOrientation = orientation === 'row'; + + const movedRef = useRef(false); + const [dragState, setDragState] = useState<{ + startPos: number; + originalHeight: number; + originalWidth: number; + } | null>(null); + + const startDrag = useCallback( + (ev: React.MouseEvent) => { + if (!state) return; + + const dims = TableMap.get(state.block); + movedRef.current = false; + + setDragState({ + startPos: isRowOrientation ? ev.clientY : ev.clientX, + originalHeight: dims.height, + originalWidth: dims.width + }); + + onMouseDown(); + ev.preventDefault(); + }, + [state, isRowOrientation, onMouseDown] + ); + + const handleClick = useCallback(() => { + if (movedRef.current || !editor || !state) return; + + runPreservingCursor(editor, () => { + selectLastCell(editor, state.block, state.blockPos, orientation); + + if (isRowOrientation) { + editor.commands.addRowAfter(); + } else { + editor.commands.addColumnAfter(); + } + }); + }, [editor, isRowOrientation, orientation, state]); + + useEffect(() => { + if (!dragState || !editor || !state) return; + + const handleMove = (ev: MouseEvent) => { + movedRef.current = true; + + const currentPos = isRowOrientation ? ev.clientY : ev.clientX; + const diff = currentPos - dragState.startPos; + const cellSize = isRowOrientation ? EMPTY_CELL_HEIGHT : EMPTY_CELL_WIDTH; + + const currentDims = TableMap.get(state.block); + const currentCount = isRowOrientation + ? currentDims.height + : currentDims.width; + const originalCount = isRowOrientation + ? dragState.originalHeight + : dragState.originalWidth; + + const newCount = Math.max( + 1, + originalCount + marginRound(diff / cellSize, 0.3) + ); + const delta = newCount - currentCount; + + if (delta === 0) return; + + // Add rows/columns + if (delta > 0) { + runPreservingCursor(editor, () => { + selectLastCell(editor, state.block, state.blockPos, orientation); + + for (let i = 0; i < delta; i++) { + if (isRowOrientation) { + editor.commands.addRowAfter(); + } else { + editor.commands.addColumnAfter(); + } + } + }); + } + // Remove rows/columns - but only if they're empty + else { + runPreservingCursor(editor, () => { + const absDelta = Math.abs(delta); + + const emptyCount = isRowOrientation + ? countEmptyRowsFromEnd(editor, state.blockPos) + : countEmptyColumnsFromEnd(editor, state.blockPos); + + // Only remove up to the number of empty cells, and keep at least 1 + const safeToRemove = Math.min(absDelta, emptyCount, currentCount - 1); + + selectLastCell(editor, state.block, state.blockPos, orientation); + + for (let i = 0; i < safeToRemove; i++) { + if (isRowOrientation) { + editor.commands.deleteRow(); + } else { + editor.commands.deleteColumn(); + } + } + }); + } + }; + + const handleUp = () => { + setDragState(null); + onMouseUp(); + }; + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + + return () => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + }; + }, [dragState, editor, isRowOrientation, orientation, onMouseUp, state]); + + if (!editor?.isEditable) return null; + + return ( + + ); +}; + +export interface TableExtendRowColumnButtonsProps { + editor?: Editor | null; + onMouseDown?: () => void; + onMouseUp?: () => void; +} + +export const TableExtendRowColumnButtons: React.FC< + TableExtendRowColumnButtonsProps +> = ({ editor: providedEditor, onMouseDown, onMouseUp }) => { + const { editor } = useTiptapEditor(providedEditor); + const state = useTableHandleState({ editor }); + const { columnButton, rowButton } = useTableExtendRowColumnButtonsPositioning( + state?.showAddOrRemoveColumnsButton ?? false, + state?.showAddOrRemoveRowsButton ?? false, + state?.referencePosTable ?? null + ); + + const handleDown = useCallback(() => { + if (!editor) return; + editor.commands.freezeHandles(); + onMouseDown?.(); + }, [editor, onMouseDown]); + + const handleUp = useCallback(() => { + if (!editor) return; + editor.commands.unfreezeHandles(); + onMouseUp?.(); + }, [editor, onMouseUp]); + + if (!state) return null; + + return ( + +
+ +
+ +
+ +
+
+ ); +}; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/use-table-extend-row-column.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/use-table-extend-row-column.ts new file mode 100644 index 00000000..9ffea0ae --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-extend-row-column-button/use-table-extend-row-column.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { + offset, + size, + useFloating, + useTransitionStyles, + type Placement +} from '@floating-ui/react'; + +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; + +interface TableExtendRowColumnButtonPositionResult { + isMounted: boolean; + ref: (node: HTMLElement | null) => void; + style: React.CSSProperties; +} + +interface TableExtendRowColumnButtonsPositioningResult { + rowButton: TableExtendRowColumnButtonPositionResult; + columnButton: TableExtendRowColumnButtonPositionResult; +} + +const ORIENTATION_CONFIG = { + row: { + placement: 'bottom' as Placement, + sizeProperty: 'width' + }, + column: { + placement: 'right' as Placement, + sizeProperty: 'height' + } +} as const; + +/** + * Custom hook for positioning extend buttons using Floating UI + */ +function useTableExtendRowColumnButtonPosition( + orientation: Orientation, + show: boolean, + referencePosTable: DOMRect | null +): TableExtendRowColumnButtonPositionResult { + const config = ORIENTATION_CONFIG[orientation]; + + const { refs, update, context, floatingStyles } = useFloating({ + open: show, + placement: config.placement, + middleware: [ + offset(4), + size({ + apply({ rects, elements }) { + const floating = elements.floating; + if (!floating) return; + + // Apply size based on orientation + const sizeValue = `${rects.reference[config.sizeProperty]}px`; + floating.style[config.sizeProperty] = sizeValue; + } + }) + ] + }); + + const { isMounted, styles } = useTransitionStyles(context); + + const createVirtualReference = useCallback( + (rect: DOMRect) => ({ + getBoundingClientRect: () => rect + }), + [] + ); + + useEffect(() => { + if (!referencePosTable) return; + + refs.setReference(createVirtualReference(referencePosTable)); + update(); + }, [referencePosTable, refs, update, createVirtualReference]); + + return useMemo( + () => ({ + isMounted, + ref: refs.setFloating, + style: { + display: 'flex', + ...styles, + ...floatingStyles + } as React.CSSProperties + }), + [floatingStyles, isMounted, refs.setFloating, styles] + ); +} + +/** + * Hook for managing positioning of both row and column extend buttons + */ +export function useTableExtendRowColumnButtonsPositioning( + showAddOrRemoveColumnsButton: boolean, + showAddOrRemoveRowsButton: boolean, + referencePosTable: DOMRect | null +): TableExtendRowColumnButtonsPositioningResult { + const rowButton = useTableExtendRowColumnButtonPosition( + 'row', + showAddOrRemoveRowsButton, + referencePosTable + ); + + const columnButton = useTableExtendRowColumnButtonPosition( + 'column', + showAddOrRemoveColumnsButton, + referencePosTable + ); + + return useMemo( + () => ({ + rowButton, + columnButton + }), + [rowButton, columnButton] + ); +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/index.tsx new file mode 100644 index 00000000..40811aed --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-fit-to-width-button'; +export * from './use-table-fit-to-width'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/table-fit-to-width-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/table-fit-to-width-button.tsx new file mode 100644 index 00000000..9bdb6b6f --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/table-fit-to-width-button.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { forwardRef, useCallback } from 'react'; + +// --- Hooks --- +import { useTableFitToWidth } from '@workspace/editor/components/tiptap-node/table-node/ui/table-fit-to-width-button/use-table-fit-to-width'; +import type { UseTableFitToWidthConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-fit-to-width-button/use-table-fit-to-width'; +// --- Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableFitToWidthButtonProps + extends Omit, UseTableFitToWidthConfig { + text?: string; +} + +/** + * Button component for fitting table to container width. + * + * This component provides a user interface for toggling table width between + * fixed column widths and container-fitting behavior. It integrates with the + * Tiptap table extension to modify table layout properties. + * + * @example + * ```tsx + * console.log('Width changed')} + * /> + * ``` + */ +export const TableFitToWidthButton = forwardRef< + HTMLButtonElement, + TableFitToWidthButtonProps +>( + ( + { + editor: providedEditor, + hideWhenUnavailable = false, + onWidthAdjusted, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, canFitToWidth, label, Icon, handleFitToWidth } = + useTableFitToWidth({ + editor, + hideWhenUnavailable, + onWidthAdjusted + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleFitToWidth(); + }, + [handleFitToWidth, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableFitToWidthButton.displayName = 'TableFitToWidthButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/use-table-fit-to-width.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/use-table-fit-to-width.ts new file mode 100644 index 00000000..d35ebc4e --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-fit-to-width-button/use-table-fit-to-width.ts @@ -0,0 +1,238 @@ +import { useCallback } from 'react'; +import type { Editor } from '@tiptap/react'; + +// --Icons --- +import { MoveHorizontalIcon } from '@workspace/editor/components/tiptap-icons/move-horizontal-icon'; +import { + getTable, + RESIZE_MIN_WIDTH +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export interface UseTableFitToWidthConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * Hide the button when the action isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Called after the table width is successfully adjusted. + */ + onWidthAdjusted?: () => void; +} + +/** + * Required Tiptap extensions for this feature to work. + * - `table` to target the node and update attributes + * - `tableHandleExtension` (or your table controls) to ensure table tooling is enabled + */ +const REQUIRED_EXTENSIONS = ['table', 'tableHandleExtension']; + +/** + * Returns whether a "fit to width" action can run in the current state. + * Checks: editor presence, editability, required extensions, + * and that the selection is somewhere inside a table. + */ +function canFitTableToWidth(editor: Editor | null): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + return ( + editor.isActive('table') || + editor.isActive('tableCell') || + editor.isActive('tableHeader') + ); + } catch { + return false; + } +} + +/** + * Automatically adjusts table column widths to fit the editor's available width. + * + * This function finds the table containing the current selection and distributes + * the available editor width equally across all columns, accounting for padding + * and respecting any user-configured minimum cell width settings. + * + * @param editor - The ProseMirror editor instance, or null + * @returns true if the table width was successfully set, false otherwise + */ +function setTableAutoWidth(editor: Editor | null): boolean { + if (!canFitTableToWidth(editor) || !editor) return false; + + try { + const table = getTable(editor); + if (!table) return false; + + // Calculate the editor width available for the table + const editorElement = editor.view.dom as HTMLElement; + const style = getComputedStyle(editorElement); + + const paddingLeft = parseFloat(style.paddingLeft) || 0; + const paddingRight = parseFloat(style.paddingRight) || 0; + + const editorWidth = editorElement.clientWidth - paddingLeft - paddingRight; + + const columnCount = table.map.width; + if (columnCount === 0) return false; + + let colWidth = 0; + const availableWidth = editorWidth - columnCount - 8; + colWidth = Math.floor(availableWidth / columnCount); + + // We are not using what what user set cellMinWidth + // Instead, we use a reasonable minimum width for usability. + const finalColWidth = Math.max(colWidth, RESIZE_MIN_WIDTH); + + const tr = editor.state.tr; + table.node.descendants((child, childPos) => { + if ( + child.type.name === 'tableCell' || + child.type.name === 'tableHeader' + ) { + const absolutePos = table.start + childPos; + const colspan = child.attrs.colspan || 1; + + const colwidthArray = Array(colspan).fill(finalColWidth); + tr.setNodeMarkup(absolutePos, undefined, { + ...child.attrs, + colwidth: colwidthArray + }); + } + }); + + if (tr.docChanged) { + editor.view.dispatch(tr); + } + + return true; + } catch (error) { + console.error('Error setting table auto width:', error); + return false; + } +} + +/** + * Executes the "fit to width" operation. Safely no-ops if unavailable. + * Returns `true` on success, `false` otherwise. + */ +function tableFitToWidth({ editor }: { editor: Editor | null }) { + if (!canFitTableToWidth(editor) || !editor) { + return false; + } + + try { + return setTableAutoWidth(editor); + } catch (error) { + console.error('Error adjusting table width:', error); + return false; + } +} + +/** + * Determines whether a UI control should be visible based on the editor + * state and `hideWhenUnavailable` setting. + */ +function shouldShowButton({ + editor, + hideWhenUnavailable +}: { + editor: Editor | null; + hideWhenUnavailable: boolean; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + return hideWhenUnavailable ? canFitTableToWidth(editor) : true; +} + +/** + * React hook that provides a **Fit table to container width** action for Tiptap. + * + * What it does: + * - Sets the current table's `width` to `"100%"` + * - Clears cell-level `colwidth` so the table can expand fluidly + * + * Returned API: + * - `isVisible`: whether a button should be shown + * - `canFitToWidth`: whether the action can execute now + * - `handleFitToWidth()`: runs the action; returns `true` on success + * - `label`: UI label, e.g. "Fit to width" + * - `Icon`: a presentational icon component + * + * @example + * // Minimal button + * function FitToWidthButton() { + * const { isVisible, canFitToWidth, handleFitToWidth, label, Icon } = + * useTableFitToWidth({ hideWhenUnavailable: true }) + * + * if (!isVisible) return null + * return ( + * + * ) + * } + * + * @example + * // Using a provided editor instance and a success callback + * function FitToWidthWithCallback({ editor }: { editor: Editor }) { + * const { handleFitToWidth, canFitToWidth } = useTableFitToWidth({ + * editor, + * hideWhenUnavailable: true, + * onWidthAdjusted: () => console.log("Table set to auto width"), + * }) + * return ( + * + * ) + * } + */ +export function useTableFitToWidth(config: UseTableFitToWidthConfig = {}) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onWidthAdjusted + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const isVisible = shouldShowButton({ + editor, + hideWhenUnavailable + }); + + const canPerformAction = canFitTableToWidth(editor); + + const handleFitToWidth = useCallback(() => { + const success = tableFitToWidth({ editor }); + if (success) onWidthAdjusted?.(); + return success; + }, [editor, onWidthAdjusted]); + + const label = 'Fit to width'; + const Icon = MoveHorizontalIcon; + + return { + isVisible, + canFitToWidth: canPerformAction, + handleFitToWidth, + label, + Icon + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/index.tsx new file mode 100644 index 00000000..fdaad60d --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/index.tsx @@ -0,0 +1 @@ +export * from './table-handle-menu'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/table-handle-menu.scss b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/table-handle-menu.scss new file mode 100644 index 00000000..8de20ce4 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/table-handle-menu.scss @@ -0,0 +1,54 @@ +:root { + --tt-table-handle-bg-color: var(--tt-gray-light-a-100); +} + +.dark { + --tt-table-handle-bg-color: var(--tt-gray-dark-a-100); +} + +.tiptap-table-handle-menu { + border: none; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--tt-table-handle-bg-color); + border-radius: var(--tt-radius-lg); + cursor: grab; + + .tiptap-button-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; + } + + &.menu-opened { + background-color: var(--tt-brand-color-500); + + .tiptap-button-icon { + color: var(--white); + } + } + + &.is-dragging { + cursor: grabbing; + background-color: var(--tt-brand-color-500); + + .tiptap-button-icon { + color: var(--white); + } + } + + &.row { + width: 0.75rem; + height: var(--table-handle-ref-height); + } + + &.column { + height: 0.75rem; + width: var(--table-handle-ref-width); + + .tiptap-button-icon { + transform: rotate(90deg); + } + } +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/table-handle-menu.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/table-handle-menu.tsx new file mode 100644 index 00000000..354486c4 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle-menu/table-handle-menu.tsx @@ -0,0 +1,767 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState +} from 'react'; +import type { Node } from '@tiptap/pm/model'; +import { TableMap } from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { MoreVerticalIcon } from '@workspace/editor/components/tiptap-icons/more-vertical-icon'; +import { dragEnd } from '@workspace/editor/components/tiptap-node/table-node/extensions/table-handle'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { selectCellsByCoords } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { useTableAddRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-add-row-column-button'; +import { TableAlignMenu } from '@workspace/editor/components/tiptap-node/table-node/ui/table-alignment-menu'; +import { useTableClearRowColumnContent } from '@workspace/editor/components/tiptap-node/table-node/ui/table-clear-row-column-content-button'; +import { useTableDeleteRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-delete-row-column-button'; +// --- Tiptap UI --- +import { useTableDuplicateRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-duplicate-row-column-button'; +import { useTableHeaderRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-header-row-column-button'; +import { useTableMoveRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-move-row-column-button'; +import { useTableSortRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-sort-row-column-button'; +// --- UI Primitives --- +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { + Combobox, + ComboboxList +} from '@workspace/editor/components/tiptap-ui-primitive/combobox'; +import { + Menu, + MenuButton, + MenuContent, + MenuGroup, + MenuItem +} from '@workspace/editor/components/tiptap-ui-primitive/menu'; +import { Separator } from '@workspace/editor/components/tiptap-ui-primitive/separator'; +import { ColorMenu } from '@workspace/editor/components/tiptap-ui/color-menu'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +import { + cn, + isValidPosition, + SR_ONLY +} from '@workspace/editor/lib/tiptap-utils'; + +import './table-handle-menu.scss'; + +/* ------------------------------------------------------------------------------------------------- + * Types & Interfaces + * ----------------------------------------------------------------------------------------------- */ + +interface BaseProps { + editor?: Editor | null; + orientation: Orientation; + index?: number; + tableNode?: Node; + tablePos?: number; +} + +interface TableHandleMenuProps extends BaseProps { + onToggleOtherHandle?: (visible: boolean) => void; + onOpenChange?: (open: boolean) => void; + dragStart?: (e: React.DragEvent) => void; +} + +type TableHandleContextValue = BaseProps; + +interface TableActionItemProps { + icon: React.ComponentType<{ + className?: string; + style?: React.CSSProperties; + }>; + label: string; + onClick: () => void; + disabled?: boolean; + isActive?: boolean; + shortcutBadge?: React.ReactNode; +} + +const MENU_PLACEMENT_MAP: Record< + Orientation, + React.ComponentProps['placement'] +> = { + row: 'top-start', + column: 'bottom-start' +}; + +const ARIA_LABELS: Record = { + row: 'Row actions', + column: 'Column actions' +}; + +/* ------------------------------------------------------------------------------------------------- + * Context + * ----------------------------------------------------------------------------------------------- */ + +const TableHandleContext = createContext(null); + +function useTableHandleContext() { + const context = useContext(TableHandleContext); + if (!context) { + throw new Error( + 'useTableHandleContext must be used within TableHandleProvider' + ); + } + return context; +} + +/* ------------------------------------------------------------------------------------------------- + * Hooks + * ----------------------------------------------------------------------------------------------- */ + +/** + * Hook to manage table handle menu state and interactions + */ +function useTableHandleMenu( + onToggleOtherHandle?: (visible: boolean) => void, + onOpenChange?: (open: boolean) => void +) { + const { editor, orientation, index, tableNode, tablePos } = + useTableHandleContext(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const menuPlacement = useMemo( + () => MENU_PLACEMENT_MAP[orientation], + [orientation] + ); + + const selectRowOrColumn = useCallback(() => { + if ( + !editor || + !tableNode || + !isValidPosition(tablePos) || + !isValidPosition(index) + ) + return; + + try { + const { width, height } = TableMap.get(tableNode); + const start = + orientation === 'row' ? { row: index, col: 0 } : { row: 0, col: index }; + const end = + orientation === 'row' + ? { row: index, col: width - 1 } + : { row: height - 1, col: index }; + + selectCellsByCoords(editor, tablePos, [start, end], { + mode: 'dispatch', + dispatch: editor.view.dispatch.bind(editor.view) + }); + } catch (error) { + console.warn('Failed to select row/column:', error); + } + }, [editor, tableNode, tablePos, orientation, index]); + + const handleMenuToggle = useCallback( + (isOpen: boolean) => { + if (!editor) return; + + setIsMenuOpen(isOpen); + onOpenChange?.(isOpen); + + if (isOpen) { + editor.commands.freezeHandles(); + selectRowOrColumn(); + onToggleOtherHandle?.(false); + } else { + editor.commands.unfreezeHandles(); + onToggleOtherHandle?.(true); + } + }, + [editor, onOpenChange, onToggleOtherHandle, selectRowOrColumn] + ); + + const resetMenu = useCallback(() => { + if (!editor) return; + + setIsMenuOpen(false); + onOpenChange?.(false); + editor.commands.unfreezeHandles(); + onToggleOtherHandle?.(true); + }, [editor, onOpenChange, onToggleOtherHandle]); + + return { + isMenuOpen, + isDragging, + setIsDragging, + menuPlacement, + handleMenuToggle, + resetMenu + }; +} + +/** + * Hook to get filtered action items based on orientation + */ +function useTableActionItems() { + const { editor, index, orientation, tablePos } = useTableHandleContext(); + + const deleteAction = useTableDeleteRowColumn({ + editor, + index, + orientation, + tablePos + }); + const duplicateAction = useTableDuplicateRowColumn({ + editor, + index, + orientation, + tablePos + }); + + // Sort actions + const sortAscAction = useTableSortRowColumn({ + editor, + tablePos, + index, + orientation, + direction: 'asc', + hideWhenUnavailable: true + }); + + const sortDescAction = useTableSortRowColumn({ + editor, + tablePos, + index, + orientation, + direction: 'desc', + hideWhenUnavailable: true + }); + + const clearContentAction = useTableClearRowColumnContent({ + editor, + index, + orientation, + tablePos, + resetAttrs: true, + hideWhenUnavailable: true + }); + + const headerAction = useTableHeaderRowColumn({ + editor, + index, + orientation, + tablePos, + hideWhenUnavailable: true + }); + + const moveUpAction = useTableMoveRowColumn({ + editor, + index, + tablePos, + orientation: 'row', + direction: 'up', + hideWhenUnavailable: true + }); + + const moveDownAction = useTableMoveRowColumn({ + editor, + index, + tablePos, + orientation: 'row', + direction: 'down', + hideWhenUnavailable: true + }); + + const moveLeftAction = useTableMoveRowColumn({ + editor, + index, + tablePos, + orientation: 'column', + direction: 'left', + hideWhenUnavailable: true + }); + + const moveRightAction = useTableMoveRowColumn({ + editor, + index, + tablePos, + orientation: 'column', + direction: 'right', + hideWhenUnavailable: true + }); + + const addAbove = useTableAddRowColumn({ + editor, + index, + tablePos, + orientation: 'row', + side: 'above', + hideWhenUnavailable: true + }); + + const addBelow = useTableAddRowColumn({ + editor, + index, + tablePos, + orientation: 'row', + side: 'below', + hideWhenUnavailable: true + }); + + const addLeft = useTableAddRowColumn({ + editor, + index, + tablePos, + orientation: 'column', + side: 'left', + hideWhenUnavailable: true + }); + + const addRight = useTableAddRowColumn({ + editor, + index, + tablePos, + orientation: 'column', + side: 'right', + hideWhenUnavailable: true + }); + + const moveActions = useMemo( + () => ({ + moveUp: moveUpAction, + moveDown: moveDownAction, + moveLeft: moveLeftAction, + moveRight: moveRightAction + }), + [moveUpAction, moveDownAction, moveLeftAction, moveRightAction] + ); + + const addActions = useMemo( + () => ({ + addAbove, + addBelow, + addLeft, + addRight + }), + [addAbove, addBelow, addLeft, addRight] + ); + + const sortActions = useMemo( + () => ({ + sortAsc: sortAscAction, + sortDesc: sortDescAction + }), + [sortAscAction, sortDescAction] + ); + + const getSortItems = useCallback(() => { + const items: TableActionItemProps[] = []; + + if (sortActions.sortAsc.isVisible) { + items.push({ + icon: sortActions.sortAsc.Icon, + label: sortActions.sortAsc.label, + disabled: !sortActions.sortAsc.canSortRowColumn, + onClick: sortActions.sortAsc.handleSort + }); + } + + if (sortActions.sortDesc.isVisible) { + items.push({ + icon: sortActions.sortDesc.Icon, + label: sortActions.sortDesc.label, + disabled: !sortActions.sortDesc.canSortRowColumn, + onClick: sortActions.sortDesc.handleSort + }); + } + + return items; + }, [sortActions]); + + const getActionItems = useCallback(() => { + const items: TableActionItemProps[] = []; + + if (orientation === 'row') { + if (addActions.addAbove.isVisible) { + items.push({ + icon: addActions.addAbove.Icon, + label: addActions.addAbove.label, + disabled: !addActions.addAbove.canAddRowColumn, + onClick: addActions.addAbove.handleAdd + }); + } + if (addActions.addBelow.isVisible) { + items.push({ + icon: addActions.addBelow.Icon, + label: addActions.addBelow.label, + disabled: !addActions.addBelow.canAddRowColumn, + onClick: addActions.addBelow.handleAdd + }); + } + } else { + if (addActions.addLeft.isVisible) { + items.push({ + icon: addActions.addLeft.Icon, + label: addActions.addLeft.label, + disabled: !addActions.addLeft.canAddRowColumn, + onClick: addActions.addLeft.handleAdd + }); + } + if (addActions.addRight.isVisible) { + items.push({ + icon: addActions.addRight.Icon, + label: addActions.addRight.label, + disabled: !addActions.addRight.canAddRowColumn, + onClick: addActions.addRight.handleAdd + }); + } + } + + return items; + }, [orientation, addActions]); + + const getMoveItems = useCallback(() => { + const items: TableActionItemProps[] = []; + + if (orientation === 'row') { + if (moveActions.moveUp.isVisible) { + items.push({ + icon: moveActions.moveUp.Icon, + label: moveActions.moveUp.label, + disabled: !moveActions.moveUp.canMoveRowColumn, + onClick: moveActions.moveUp.handleMove + }); + } + if (moveActions.moveDown.isVisible) { + items.push({ + icon: moveActions.moveDown.Icon, + label: moveActions.moveDown.label, + disabled: !moveActions.moveDown.canMoveRowColumn, + onClick: moveActions.moveDown.handleMove + }); + } + } else { + if (moveActions.moveLeft.isVisible) { + items.push({ + icon: moveActions.moveLeft.Icon, + label: moveActions.moveLeft.label, + disabled: !moveActions.moveLeft.canMoveRowColumn, + onClick: moveActions.moveLeft.handleMove + }); + } + if (moveActions.moveRight.isVisible) { + items.push({ + icon: moveActions.moveRight.Icon, + label: moveActions.moveRight.label, + disabled: !moveActions.moveRight.canMoveRowColumn, + onClick: moveActions.moveRight.handleMove + }); + } + } + + return items; + }, [orientation, moveActions]); + + return { + deleteAction, + duplicateAction, + clearContentAction, + headerAction, + addItems: getActionItems(), + moveItems: getMoveItems(), + sortItems: getSortItems() + }; +} + +/* ------------------------------------------------------------------------------------------------- + * Components + * ----------------------------------------------------------------------------------------------- */ + +/** + * Individual action item component + */ +const TableActionItem = ({ + icon: Icon, + label, + onClick, + disabled = false, + isActive = false, + shortcutBadge +}: TableActionItemProps) => ( + + } + onClick={onClick} + disabled={disabled} + > + + {label} + {shortcutBadge} + +); + +/** + * Action group component containing add and delete actions + */ +const TableActionGroup = () => { + const { index, orientation } = useTableHandleContext(); + const { + deleteAction, + duplicateAction, + clearContentAction, + headerAction, + addItems, + moveItems, + sortItems + } = useTableActionItems(); + + const hasActions = + deleteAction.isVisible || + duplicateAction.isVisible || + clearContentAction.isVisible; + const hasAddItems = addItems.length > 0; + const hasMoveItems = moveItems.length > 0; + const hasSortItems = sortItems.length > 0; + const hasHeaderAction = headerAction.isVisible && index === 0; + + if ( + !hasActions && + !hasAddItems && + !hasMoveItems && + !hasSortItems && + !hasHeaderAction + ) { + return null; + } + + return ( + <> + {/* Header Toggle Action - Only for first row/column */} + {hasHeaderAction && ( + <> + + + + + + )} + + {/* Move Actions */} + {hasMoveItems && ( + <> + + {moveItems.map((item, i) => ( + + ))} + + + + )} + + {/* Add Actions */} + {hasAddItems && ( + <> + + {addItems.map((item, i) => ( + + ))} + + + + )} + + {/* Sort Actions */} + {hasSortItems && ( + <> + + {sortItems.map((item, i) => ( + + ))} + + + + )} + + {/* Actions */} + <> + + + + {clearContentAction.isVisible && ( + + )} + + + + + {hasActions && ( + + {duplicateAction.isVisible && ( + + )} + + {deleteAction.isVisible && ( + + )} + + )} + + ); +}; + +/** + * Menu content component + */ +const TableActionMenu = () => { + const { resetMenu } = useTableHandleMenu(); + + return ( + + + + + + + ); +}; + +/** + * Main table handle menu component + */ +export const TableHandleMenu = ({ + editor: providedEditor, + orientation, + index, + tableNode, + tablePos, + onToggleOtherHandle, + onOpenChange, + dragStart +}: TableHandleMenuProps) => { + const { editor } = useTiptapEditor(providedEditor); + + const contextValue = useMemo( + () => ({ + editor, + orientation, + index, + tableNode, + tablePos + }), + [editor, orientation, index, tableNode, tablePos] + ); + + return ( + + + + ); +}; + +/** + * Internal menu content component + */ +const TableHandleMenuContent = ({ + onToggleOtherHandle, + onOpenChange, + dragStart +}: Pick< + TableHandleMenuProps, + 'onToggleOtherHandle' | 'onOpenChange' | 'dragStart' +>) => { + const { orientation } = useTableHandleContext(); + const { + isMenuOpen, + isDragging, + setIsDragging, + menuPlacement, + handleMenuToggle + } = useTableHandleMenu(onToggleOtherHandle, onOpenChange); + + const ariaLabel = ARIA_LABELS[orientation]; + + const handleDragStart = useCallback( + (e: React.DragEvent) => { + setIsDragging(true); + dragStart?.(e); + }, + [dragStart, setIsDragging] + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + dragEnd(); + }, [setIsDragging]); + + return ( + + + + } + > + + + ); +}; + +export { TableActionMenu }; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/index.tsx new file mode 100644 index 00000000..f97112aa --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/index.tsx @@ -0,0 +1,2 @@ +export * from './table-handle'; +export * from './use-table-handle-positioning'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/table-handle.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/table-handle.tsx new file mode 100644 index 00000000..2c8d208a --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/table-handle.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import type { ComponentType } from 'react'; +import { FloatingPortal } from '@floating-ui/react'; +import type { Node } from '@tiptap/pm/model'; +import type { Editor } from '@tiptap/react'; + +import { + colDragStart, + rowDragStart +} from '@workspace/editor/components/tiptap-node/table-node/extensions/table-handle'; +import { useTableHandleState } from '@workspace/editor/components/tiptap-node/table-node/hooks/use-table-handle-state'; +import { type Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Components --- +import { TableHandleMenu } from '@workspace/editor/components/tiptap-node/table-node/ui/table-handle-menu'; +import { useTableHandlePositioning } from '@workspace/editor/components/tiptap-node/table-node/ui/table-handle/use-table-handle-positioning'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableHandleButtonProps { + editor: Editor; + orientation: Orientation; + index?: number; + tablePos?: number; + tableNode?: Node; + onToggleOtherHandle?: (visible: boolean) => void; + onOpenChange?: (open: boolean) => void; +} + +export interface TableHandleRenderProps { + editor: Editor; + state: ReturnType; + rowHandle: ReturnType['rowHandle']; + colHandle: ReturnType['colHandle']; + toggleRowVisibility: (visible: boolean) => void; + toggleColumnVisibility: (visible: boolean) => void; +} + +export interface TableHandleProps { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null; + + /** + * Custom component to render for row handles. + * If not provided, uses the default TableHandleMenu. + */ + rowButton?: ComponentType; + + /** + * Custom component to render for column handles. + * If not provided, uses the default TableHandleMenu. + */ + columnButton?: ComponentType; +} + +/** + * Main table handle component that manages the positioning and rendering + * of table row/column handles, extend buttons, and context menus. + * + * This component can be extended with custom row and column buttons, + * or completely customized using the render prop pattern. + */ +export function TableHandle({ + editor: providedEditor, + rowButton: CustomRowButton, + columnButton: CustomColumnButton +}: TableHandleProps) { + const { editor } = useTiptapEditor(providedEditor); + const state = useTableHandleState({ editor }); + + const [isRowVisible, setIsRowVisible] = useState(true); + const [isColumnVisible, setIsColumnVisible] = useState(true); + const [menuOpen, setMenuOpen] = useState(null); + + const draggingState = useMemo(() => { + if (!state?.draggingState) return undefined; + + return { + draggedCellOrientation: state.draggingState.draggedCellOrientation, + mousePos: state.draggingState.mousePos, + initialOffset: state.draggingState.initialOffset + }; + }, [state?.draggingState]); + + const { rowHandle, colHandle } = useTableHandlePositioning( + state?.show || false, + state?.referencePosCell || null, + state?.referencePosTable || null, + draggingState + ); + + const toggleRowVisibility = useCallback((visible: boolean) => { + setIsRowVisible(visible); + }, []); + + const toggleColumnVisibility = useCallback((visible: boolean) => { + setIsColumnVisible(visible); + }, []); + + const handleMenuOpenChange = useCallback( + (type: 'row' | 'column', open: boolean) => { + setMenuOpen(open ? type : null); + }, + [] + ); + + if (!editor || !state) return null; + + const hasValidRowIndex = typeof state.rowIndex === 'number'; + const hasValidColIndex = typeof state.colIndex === 'number'; + + const shouldShowRow = + (isRowVisible && rowHandle.isMounted && hasValidRowIndex) || + menuOpen === 'row'; + + const shouldShowColumn = + (isColumnVisible && colHandle.isMounted && hasValidColIndex) || + menuOpen === 'column'; + + const RowButton = CustomRowButton || TableHandleMenu; + const ColumnButton = CustomColumnButton || TableHandleMenu; + + return ( + + {shouldShowRow && ( +
+ handleMenuOpenChange('row', open)} + /> +
+ )} + + {shouldShowColumn && ( +
+ handleMenuOpenChange('column', open)} + /> +
+ )} +
+ ); +} + +TableHandle.displayName = 'TableHandle'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/use-table-handle-positioning.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/use-table-handle-positioning.ts new file mode 100644 index 00000000..119699d1 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-handle/use-table-handle-positioning.ts @@ -0,0 +1,256 @@ +import { useEffect, useMemo } from 'react'; +import { + offset, + size, + useFloating, + useTransitionStyles +} from '@floating-ui/react'; + +import { clamp } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; + +type Orientation = 'row' | 'col' | 'cell'; + +type DraggingState = { + draggedCellOrientation: Exclude; + mousePos: number; + initialOffset?: number; +}; + +/** + * Creates a DOMRect for row handle positioning + */ +function makeRowRect( + cell: DOMRect, + table: DOMRect, + dragging?: DraggingState +): DOMRect { + if (dragging?.draggedCellOrientation === 'row') { + // Apply the initial offset to maintain handle position + const adjustedY = dragging.mousePos + (dragging.initialOffset ?? 0); + const clampedY = clamp(adjustedY, table.y, table.bottom - cell.height); + return new DOMRect(table.x, clampedY, table.width, cell.height); + } + return new DOMRect(table.x, cell.y, table.width, cell.height); +} + +/** + * Creates a DOMRect for column handle positioning + */ +function makeColRect( + cell: DOMRect, + table: DOMRect, + dragging?: DraggingState +): DOMRect { + if (dragging?.draggedCellOrientation === 'col') { + // Apply the initial offset to maintain handle position + const adjustedX = dragging.mousePos + (dragging.initialOffset ?? 0); + const clampedX = clamp(adjustedX, table.x, table.right - cell.width); + return new DOMRect(clampedX, table.y, cell.width, table.height); + } + return new DOMRect(cell.x, table.y, cell.width, table.height); +} + +/** + * Creates a DOMRect for cell handle positioning + */ +function makeCellRect(cell: DOMRect): DOMRect { + return new DOMRect(cell.x, cell.y, cell.width, 0); +} + +/** + * Gets the placement configuration for different handle orientations + */ +function getPlacement(orientation: Orientation) { + switch (orientation) { + case 'row': + return 'left' as const; + case 'col': + return 'top' as const; + case 'cell': + default: + return 'bottom-end' as const; + } +} + +/** + * Gets the offset configuration for different handle orientations + */ +function getOffset(orientation: Orientation) { + switch (orientation) { + case 'row': + return 4; + case 'col': + return 4; + case 'cell': + default: + return { mainAxis: 1, crossAxis: -1 } as const; + } +} + +/** + * Factory function to create DOMRect based on orientation + */ +function rectFactory( + orientation: Orientation, + cell: DOMRect, + table: DOMRect, + dragging?: DraggingState +): DOMRect { + switch (orientation) { + case 'row': + return makeRowRect(cell, table, dragging); + case 'col': + return makeColRect(cell, table, dragging); + case 'cell': + default: + return makeCellRect(cell); + } +} + +/** + * Hook for positioning individual table handles using Floating UI + */ +export function useTableHandlePosition( + orientation: Orientation, + show: boolean, + referencePosCell: DOMRect | null, + referencePosTable: DOMRect | null, + draggingState?: DraggingState +): { + isMounted: boolean; + ref: (node: HTMLElement | null) => void; + style: React.CSSProperties; +} { + const placement = useMemo(() => getPlacement(orientation), [orientation]); + const offsetValue = useMemo(() => getOffset(orientation), [orientation]); + + const { refs, update, context, floatingStyles } = useFloating({ + open: show, + placement, + middleware: [ + offset(offsetValue), + size({ + apply({ rects, elements }) { + if (!elements.floating) return; + + const refWidth = + (orientation === 'col' + ? (referencePosCell?.width ?? referencePosTable?.width) + : referencePosTable?.width) ?? rects.reference.width; + + const refHeight = + (orientation === 'row' + ? (referencePosCell?.height ?? referencePosTable?.height) + : referencePosTable?.height) ?? rects.reference.height; + + // Set CSS custom properties for styling + elements.floating.style.setProperty( + '--table-handle-ref-width', + `${refWidth}px` + ); + elements.floating.style.setProperty( + '--table-handle-ref-height', + `${refHeight}px` + ); + + // Set the main size dimension based on orientation + const mainSize = orientation === 'row' ? refHeight : refWidth; + elements.floating.style.setProperty( + '--table-handle-available-size', + `${mainSize}px` + ); + } + }) + ] + }); + + const { isMounted, styles } = useTransitionStyles(context); + + useEffect(() => { + update(); + }, [ + update, + show, + orientation, + referencePosCell, + referencePosTable, + draggingState + ]); + + // Provide a virtual reference rect to Floating UI + useEffect(() => { + // Nothing to reference yet + if (!referencePosCell || !referencePosTable) return; + + // Ignore cell handle while dragging (matches original behavior) + if (draggingState && orientation === 'cell') return; + + refs.setReference({ + getBoundingClientRect: () => + rectFactory( + orientation, + referencePosCell, + referencePosTable, + draggingState + ) + }); + }, [refs, orientation, referencePosCell, referencePosTable, draggingState]); + + return useMemo( + () => ({ + isMounted, + ref: refs.setFloating, + style: { + display: 'flex', + ...styles, + ...floatingStyles + } + }), + [isMounted, refs.setFloating, styles, floatingStyles] + ); +} + +/** + * Hook for managing positioning of all table handles (row, column, and cell) + * + * @param show - Whether handles should be shown + * @param referencePosCell - The bounding rect of the current cell + * @param referencePosTable - The bounding rect of the table + * @param draggingState - Current dragging state if any + * @returns Positioning data for all handle types + */ +export function useTableHandlePositioning( + show: boolean, + referencePosCell: DOMRect | null, + referencePosTable: DOMRect | null, + draggingState?: DraggingState +) { + const rowHandle = useTableHandlePosition( + 'row', + show, + referencePosCell, + referencePosTable, + draggingState + ); + + const colHandle = useTableHandlePosition( + 'col', + show, + referencePosCell, + referencePosTable, + draggingState + ); + + const cellHandle = useTableHandlePosition( + 'cell', + show, + referencePosCell, + referencePosTable, + draggingState + ); + + return useMemo( + () => ({ rowHandle, colHandle, cellHandle }), + [rowHandle, colHandle, cellHandle] + ); +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/index.tsx new file mode 100644 index 00000000..3655ca2b --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-header-row-column-button'; +export * from './use-table-header-row-column'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/table-header-row-column-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/table-header-row-column-button.tsx new file mode 100644 index 00000000..716d2671 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/table-header-row-column-button.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableHeaderRowColumnConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-header-row-column-button'; +import { useTableHeaderRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-header-row-column-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableHeaderRowColumnButtonProps + extends Omit, UseTableHeaderRowColumnConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for toggling table header row/column in a Tiptap editor. + * Only works for the first row (index 0) or first column (index 0). + * + * For custom button implementations, use the `useTableHeaderRowColumn` hook instead. + */ +export const TableHeaderRowColumnButton = forwardRef< + HTMLButtonElement, + TableHeaderRowColumnButtonProps +>( + ( + { + editor: providedEditor, + index, + orientation, + hideWhenUnavailable = false, + onToggled, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleToggle, label, canToggleHeader, Icon, isActive } = + useTableHeaderRowColumn({ + editor, + index, + orientation, + hideWhenUnavailable, + onToggled + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleToggle(); + }, + [handleToggle, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableHeaderRowColumnButton.displayName = 'TableHeaderRowColumnButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/use-table-header-row-column.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/use-table-header-row-column.ts new file mode 100644 index 00000000..5c4e0957 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-header-row-column-button/use-table-header-row-column.ts @@ -0,0 +1,259 @@ +import { useCallback } from 'react'; +import type { Transaction } from '@tiptap/pm/state'; +import { CellSelection, toggleHeader } from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +import { TableHeaderColumnIcon } from '@workspace/editor/components/tiptap-icons/table-header-column-icon'; +// --- Icons --- +import { TableHeaderRowIcon } from '@workspace/editor/components/tiptap-icons/table-header-row-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + getIndexCoordinates, + getRowOrColumnCells, + getTableSelectionType, + selectCellsByCoords +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { + isExtensionAvailable, + isValidPosition +} from '@workspace/editor/lib/tiptap-utils'; + +export interface UseTableHeaderRowColumnConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The index of the row or column. Header functionality only applies to index 0. + * If omitted, will use the current selection. + */ + index?: number; + /** + * Whether you're toggling header for a row or a column. + * If omitted, will use the current selection. + */ + orientation?: Orientation; + /** + * The position of the table in the document. + * Used when there's no cell selection so we can target a specific table. + */ + tablePos?: number; + /** + * Hide the button when header toggle isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after a successful header toggle. + */ + onToggled?: () => void; +} + +const REQUIRED_EXTENSIONS = ['table']; + +export const tableHeaderRowColumnLabels: Record = { + row: 'Header row', + column: 'Header column' +}; + +export const tableHeaderRowColumnIcons = { + row: TableHeaderRowIcon, + column: TableHeaderColumnIcon +}; + +/** + * Checks if a table header row/column toggle can be performed + * in the current editor state (or at tablePos when no selection). + */ +function canToggleHeader({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + if (!selectionType) return false; + + return selectionType.index === 0; +} + +/** + * Executes the header row/column toggle. If there is no cell selection, + * it will derive the target from (index, orientation) and the table at tablePos. + */ +function toggleTableHeader({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if (!editor) return false; + if (!canToggleHeader({ editor, index, orientation, tablePos })) return false; + + try { + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + if (!selectionType) return false; + + const isRow = selectionType.orientation === 'row'; + + if (editor.state.selection instanceof CellSelection) { + return isRow + ? editor.commands.toggleHeaderRow() + : editor.commands.toggleHeaderColumn(); + } + + if (!isValidPosition(tablePos)) return false; + + const cellCoords = getIndexCoordinates({ + editor, + index: selectionType.index, + orientation: selectionType.orientation, + tablePos + }); + if (!cellCoords) return false; + + const stateWithCellSel = selectCellsByCoords(editor, tablePos, cellCoords, { + mode: 'state' + }); + if (!stateWithCellSel) return false; + + const dispatch = (tr: Transaction) => editor.view.dispatch(tr); + return isRow + ? toggleHeader('row')(stateWithCellSel, dispatch) + : toggleHeader('column')(stateWithCellSel, dispatch); + } catch { + return false; + } +} + +/** + * Determines if the header toggle button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + hideWhenUnavailable: boolean; + tablePos?: number; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + if (hideWhenUnavailable) { + return canToggleHeader({ editor, index, orientation, tablePos }); + } + + const selectionType = getTableSelectionType(editor, index, orientation); + return Boolean(selectionType); +} + +/** + * Custom hook that provides **table header row/column toggle** + * functionality for the Tiptap editor. Supports `tablePos` when + * no cell is selected. + */ +export function useTableHeaderRowColumn(config: UseTableHeaderRowColumnConfig) { + const { + editor: providedEditor, + index, + orientation, + tablePos, + hideWhenUnavailable = false, + onToggled + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const selectionType = getTableSelectionType(editor, index, orientation); + + const isVisible = shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable, + tablePos + }); + + const canPerformToggle = canToggleHeader({ + editor, + index, + orientation, + tablePos + }); + + let isActive = false; + if (editor?.state.selection instanceof CellSelection) { + isActive = editor?.isActive('tableHeader') || false; + } else { + const rowsOrCols = getRowOrColumnCells( + editor, + index, + selectionType?.orientation, + tablePos + ); + + if (rowsOrCols) { + const secondIndex = rowsOrCols.cells.length > 1 ? 1 : 0; + isActive = + rowsOrCols.cells[secondIndex]?.node?.type.name === 'tableHeader' || + false; + } + } + + const handleToggle = useCallback(() => { + const success = toggleTableHeader({ editor, index, orientation, tablePos }); + if (success) onToggled?.(); + return success; + }, [editor, index, orientation, tablePos, onToggled]); + + const label = tableHeaderRowColumnLabels[selectionType?.orientation || 'row']; + const Icon = tableHeaderRowColumnIcons[selectionType?.orientation || 'row']; + + return { + isVisible, + canToggleHeader: canPerformToggle, + handleToggle, + label, + Icon, + isActive + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/index.tsx new file mode 100644 index 00000000..7c7658cf --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-merge-split-cell-button'; +export * from './use-table-merge-split-cell'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/table-merge-split-cell-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/table-merge-split-cell-button.tsx new file mode 100644 index 00000000..5bb536da --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/table-merge-split-cell-button.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableMergeSplitCellConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-merge-split-cell-button'; +import { useTableMergeSplitCell } from '@workspace/editor/components/tiptap-node/table-node/ui/table-merge-split-cell-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableMergeSplitCellButtonProps + extends Omit, UseTableMergeSplitCellConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for merging or splitting table cells in a Tiptap editor. + * + * **Merge Cells**: When multiple cells are selected (using CellSelection), + * this button will merge them into a single cell. + * + * **Split Cell**: When a merged cell is selected, this button will split + * it back into individual cells. + * + * For custom button implementations, use the `useTableMergeSplitCell` hook instead. + * + * @example + * ```tsx + * // Merge cells button + * + * + * // Split cell button + * console.log(`${action} completed!`)} + * /> + * + * // Custom styling + * + * ``` + */ +export const TableMergeSplitCellButton = forwardRef< + HTMLButtonElement, + TableMergeSplitCellButtonProps +>( + ( + { + editor: providedEditor, + action, + hideWhenUnavailable = false, + onExecuted, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleExecute, label, canExecute, Icon } = + useTableMergeSplitCell({ + editor, + action, + hideWhenUnavailable, + onExecuted + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleExecute(); + }, + [handleExecute, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableMergeSplitCellButton.displayName = 'TableMergeSplitCellButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/use-table-merge-split-cell.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/use-table-merge-split-cell.ts new file mode 100644 index 00000000..9bcccb8c --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-merge-split-cell-button/use-table-merge-split-cell.ts @@ -0,0 +1,286 @@ +import { useCallback } from 'react'; +import { mergeCells, splitCell } from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { TableCellMergeIcon } from '@workspace/editor/components/tiptap-icons/table-cell-merge-icon'; +import { TableCellSplitIcon } from '@workspace/editor/components/tiptap-icons/table-cell-split-icon'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export type MergeSplitAction = 'merge' | 'split'; + +export interface UseTableMergeSplitCellConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The action to perform - merge or split cells. + */ + action: MergeSplitAction; + /** + * Hide the button when the action isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after a successful merge or split. + */ + onExecuted?: (action: MergeSplitAction) => void; +} + +const REQUIRED_EXTENSIONS = ['table']; + +export const tableMergeSplitCellLabels: Record = { + merge: 'Merge cells', + split: 'Split cell' +}; + +export const tableMergeSplitCellIcons = { + merge: TableCellMergeIcon, + split: TableCellSplitIcon +}; + +/** + * Checks if a table cell merge can be performed + * in the current editor state. + */ +function canMergeCells(editor: Editor | null): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + return mergeCells(editor.state, undefined); + } catch { + return false; + } +} + +/** + * Checks if a table cell split can be performed + * in the current editor state. + */ +function canSplitCell(editor: Editor | null): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + return splitCell(editor.state, undefined); + } catch { + return false; + } +} + +/** + * Executes the cell merge operation in the editor. + */ +function tableMergeCells(editor: Editor | null): boolean { + if (!canMergeCells(editor) || !editor) return false; + + try { + const { state, view } = editor; + return mergeCells(state, view.dispatch.bind(view)); + } catch (error) { + console.error('Error merging table cells:', error); + return false; + } +} + +/** + * Executes the cell split operation in the editor. + */ +function tableSplitCell(editor: Editor | null): boolean { + if (!canSplitCell(editor) || !editor) return false; + + try { + const { state, view } = editor; + return splitCell(state, view.dispatch.bind(view)); + } catch (error) { + console.error('Error splitting table cell:', error); + return false; + } +} + +/** + * Executes the merge/split operation in the editor. + */ +function tableMergeSplitCell({ + editor, + action +}: { + editor: Editor | null; + action: MergeSplitAction; +}): boolean { + if (!editor) return false; + + try { + return action === 'merge' + ? tableMergeCells(editor) + : tableSplitCell(editor); + } catch (error) { + console.error(`Error ${action}ing table cell:`, error); + return false; + } +} + +/** + * Determines if the merge/split button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + action, + hideWhenUnavailable +}: { + editor: Editor | null; + action: MergeSplitAction; + hideWhenUnavailable: boolean; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + if (hideWhenUnavailable) { + return action === 'merge' ? canMergeCells(editor) : canSplitCell(editor); + } + + return true; +} + +/** + * Custom hook that provides **table cell merge/split** + * functionality for the Tiptap editor. + * + * @example + * ```tsx + * // Simple merge button + * function MergeCellsButton() { + * const { isVisible, handleExecute, canExecute, label, Icon } = useTableMergeSplitCell({ + * action: "merge", + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Split cell button with callback + * function SplitCellButton({ editor }: { editor: Editor }) { + * const { isVisible, handleExecute, label, canExecute, Icon } = useTableMergeSplitCell({ + * editor, + * action: "split", + * hideWhenUnavailable: true, + * onExecuted: (action) => console.log(`${action} completed!`), + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Dynamic merge/split button based on context + * function MergeSplitButton() { + * const mergeAction = useTableMergeSplitCell({ + * action: "merge", + * hideWhenUnavailable: true, + * }) + * + * const splitAction = useTableMergeSplitCell({ + * action: "split", + * hideWhenUnavailable: true, + * }) + * + * if (mergeAction.isVisible) { + * return ( + * + * ) + * } + * + * if (splitAction.isVisible) { + * return ( + * + * ) + * } + * + * return null + * } + * ``` + */ +export function useTableMergeSplitCell(config: UseTableMergeSplitCellConfig) { + const { + editor: providedEditor, + action, + hideWhenUnavailable = false, + onExecuted + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const isVisible = shouldShowButton({ + editor, + action, + hideWhenUnavailable + }); + + const canPerformAction = + action === 'merge' ? canMergeCells(editor) : canSplitCell(editor); + + const handleExecute = useCallback(() => { + const success = tableMergeSplitCell({ + editor, + action + }); + + if (success) { + onExecuted?.(action); + } + return success; + }, [editor, action, onExecuted]); + + return { + isVisible, + canExecute: canPerformAction, + handleExecute, + label: tableMergeSplitCellLabels[action], + Icon: tableMergeSplitCellIcons[action] + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/index.tsx new file mode 100644 index 00000000..a77843c1 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-move-row-column-button'; +export * from './use-table-move-row-column'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/table-move-row-column-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/table-move-row-column-button.tsx new file mode 100644 index 00000000..677103e4 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/table-move-row-column-button.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableMoveRowColumnConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-move-row-column-button'; +import { useTableMoveRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-move-row-column-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableMoveRowColumnButtonProps + extends Omit, UseTableMoveRowColumnConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for moving a table row/column in a Tiptap editor. + * + * Supports moving: + * - Rows up or down + * - Columns left or right + * + * For custom button implementations, use the `useTableMoveRowColumn` hook instead. + * + * @example + * ```tsx + * // Move row up + * + * + * // Move column right + * console.log("Column moved!")} + * /> + * ``` + */ +export const TableMoveRowColumnButton = forwardRef< + HTMLButtonElement, + TableMoveRowColumnButtonProps +>( + ( + { + editor: providedEditor, + index, + orientation, + direction, + hideWhenUnavailable = false, + onMoved, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleMove, label, canMoveRowColumn, Icon } = + useTableMoveRowColumn({ + editor, + index, + orientation, + direction, + hideWhenUnavailable, + onMoved + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleMove(); + }, + [handleMove, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableMoveRowColumnButton.displayName = 'TableMoveRowColumnButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts new file mode 100644 index 00000000..c6adf6dd --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts @@ -0,0 +1,477 @@ +import { useCallback, useMemo } from 'react'; +import type { Node } from '@tiptap/pm/model'; +import type { Transaction } from '@tiptap/pm/state'; +import type { TableMap } from '@tiptap/pm/tables'; +import { + CellSelection, + columnIsHeader, + moveTableColumn, + moveTableRow, + rowIsHeader, + selectedRect +} from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +import { ArrowDownIcon } from '@workspace/editor/components/tiptap-icons/arrow-down-icon'; +// --- Icons --- +import { ArrowLeftIcon } from '@workspace/editor/components/tiptap-icons/arrow-left-icon'; +import { ArrowRightIcon } from '@workspace/editor/components/tiptap-icons/arrow-right-icon'; +import { ArrowUpIcon } from '@workspace/editor/components/tiptap-icons/arrow-up-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + cellsOverlapRectangle, + getIndexCoordinates, + getTable, + getTableSelectionType, + selectCellsByCoords +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export type MoveDirection = 'up' | 'down' | 'left' | 'right'; + +export interface UseTableMoveRowColumnConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The index of the row or column to move. + * If omitted, will use the current selection. + */ + index?: number; + /** + * Whether you're moving a row or a column. + * If omitted, will use the current selection. + */ + orientation?: Orientation; + /** + * The position of the table in the document. + */ + tablePos?: number; + /** + * The direction to move (up/down for rows, left/right for columns). + */ + direction: MoveDirection; + /** + * Hide the button when moving isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after a successful move. + */ + onMoved?: () => void; +} + +const REQUIRED_EXTENSIONS = ['tableHandleExtension']; + +export const tableMoveRowColumnLabels: Record< + Orientation, + Record +> = { + row: { + up: 'Move row up', + down: 'Move row down', + left: 'Move row left', + right: 'Move row right' + }, + column: { + up: 'Move column up', + down: 'Move column down', + left: 'Move column left', + right: 'Move column right' + } +}; + +export const tableMoveRowColumnIcons = { + up: ArrowUpIcon, + down: ArrowDownIcon, + left: ArrowLeftIcon, + right: ArrowRightIcon +}; + +function safeColumnIsHeader(map: TableMap, node: Node, index: number): boolean { + try { + return columnIsHeader(map, node, index); + } catch { + return false; + } +} + +function safeRowIsHeader(map: TableMap, node: Node, index: number): boolean { + try { + return rowIsHeader(map, node, index); + } catch { + return false; + } +} + +/** + * Validates that the direction is compatible with the orientation. + */ +function isValidDirectionForOrientation( + orientation: Orientation, + direction: MoveDirection +): boolean { + if (orientation === 'row') { + return direction === 'up' || direction === 'down'; + } else if (orientation === 'column') { + return direction === 'left' || direction === 'right'; + } + return false; +} + +/** + * Checks if a table row/column can be moved in the specified direction. + */ +function canMoveRowColumn({ + editor, + index, + orientation, + direction, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + direction: MoveDirection; + tablePos?: number; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType(editor, index, orientation); + if (!selectionType) return false; + + const { orientation: finalOrientation, index: finalIndex } = selectionType; + + if (!isValidDirectionForOrientation(finalOrientation, direction)) { + return false; + } + + // START + // This is just internal preference, you can comment it out if you want + // to allow moving header rows/columns + if ( + finalOrientation === 'row' && + safeRowIsHeader(table.map, table.node, finalIndex) + ) { + return false; + } + + if ( + finalOrientation === 'column' && + safeColumnIsHeader(table.map, table.node, finalIndex) + ) { + return false; + } + // END + + const { width, height } = table.map; + + const targetIndex = + finalOrientation === 'row' + ? direction === 'up' + ? finalIndex - 1 + : finalIndex + 1 + : direction === 'left' + ? finalIndex - 1 + : finalIndex + 1; + + const maxIndex = finalOrientation === 'row' ? height : width; + if (targetIndex < 0 || targetIndex >= maxIndex) { + return false; + } + + const sourceCoords = getIndexCoordinates({ + editor, + index: finalIndex, + orientation: finalOrientation, + tablePos + }); + const targetCoords = getIndexCoordinates({ + editor, + index: targetIndex, + orientation: finalOrientation, + tablePos + }); + if (!sourceCoords || !targetCoords) return false; + + const sourceSelection = selectCellsByCoords( + editor, + table.pos, + sourceCoords, + { mode: 'state' } + ); + if (!sourceSelection) return false; + const sourceRect = selectedRect(sourceSelection); + + const targetSelection = selectCellsByCoords( + editor, + table.pos, + targetCoords, + { mode: 'state' } + ); + if (!targetSelection) return false; + const targetRect = selectedRect(targetSelection); + + if ( + cellsOverlapRectangle(table.map, sourceRect) && + cellsOverlapRectangle(table.map, targetRect) + ) { + return false; + } + + return finalOrientation === 'row' + ? direction === 'up' + ? finalIndex > 0 + : finalIndex < height - 1 + : direction === 'left' + ? finalIndex > 0 + : finalIndex < width - 1; + } catch { + return false; + } +} + +/** + * Executes the row/column move in the editor. + */ +function tableMoveRowColumn({ + editor, + index, + orientation, + direction, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + direction: MoveDirection; + tablePos?: number; +}): boolean { + if ( + !canMoveRowColumn({ editor, index, orientation, direction, tablePos }) || + !editor + ) { + return false; + } + + try { + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType(editor, index, orientation); + if (!selectionType) return false; + + const { orientation: finalOrientation, index: from } = selectionType; + + if (!isValidDirectionForOrientation(finalOrientation, direction)) { + return false; + } + + const delta: Record = { + up: -1, + down: 1, + left: -1, + right: 1 + }; + + const to = from + delta[direction]; + + const moveOperation = + finalOrientation === 'row' ? moveTableRow : moveTableColumn; + + console.log({ from, to, finalOrientation, direction }); + + const dispatch = (tr: Transaction) => editor.view.dispatch(tr); + + if (editor.state.selection instanceof CellSelection) { + return moveOperation({ from, to, select: true, pos: table.start })( + editor.state, + dispatch + ); + } else { + const sourceCoords = getIndexCoordinates({ + editor, + index: from, + orientation: finalOrientation, + tablePos + }); + if (!sourceCoords) return false; + + const selectionState = selectCellsByCoords( + editor, + table.pos, + sourceCoords, + { mode: 'state' } + ); + + if (!selectionState) return false; + + return moveOperation({ from, to, select: true, pos: table.start })( + selectionState, + dispatch + ); + } + } catch (error) { + console.error('Error moving table row/column:', error); + return false; + } +} + +/** + * Determines if the move button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + index, + orientation, + direction, + hideWhenUnavailable, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + direction: MoveDirection; + hideWhenUnavailable: boolean; + tablePos?: number; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + const selectionType = getTableSelectionType(editor, index, orientation); + if (!selectionType) return false; + + if (!isValidDirectionForOrientation(selectionType.orientation, direction)) { + return false; + } + + return hideWhenUnavailable + ? canMoveRowColumn({ editor, index, orientation, direction, tablePos }) + : true; +} + +/** + * Custom hook that provides **table row/column moving** + * functionality for the Tiptap editor. + * + * @example + * ```tsx + * // Move row up + * function MoveRowUpButton({ rowIndex }: { rowIndex: number }) { + * const { isVisible, handleMove, canMoveRowColumn, label, Icon } = useTableMoveRowColumn({ + * index: rowIndex, + * orientation: "row", + * direction: "up", + * hideWhenUnavailable: true, + * onMoved: () => console.log("Row moved up!"), + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Move column based on current selection + * function MoveColumnButton({ direction }: { direction: "left" | "right" }) { + * const { isVisible, handleMove, label } = useTableMoveRowColumn({ + * orientation: "column", + * direction, + * hideWhenUnavailable: true, + * }) + * + * if (!isVisible) return null + * + * return + * } + * ``` + */ +export function useTableMoveRowColumn(config: UseTableMoveRowColumnConfig) { + const { + editor: providedEditor, + index, + orientation, + tablePos, + direction, + hideWhenUnavailable = false, + onMoved + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const selectionType = getTableSelectionType(editor, index, orientation); + + const isVisible = shouldShowButton({ + editor, + index, + orientation, + direction, + hideWhenUnavailable, + tablePos + }); + + const canPerformMove = canMoveRowColumn({ + editor, + index, + orientation, + direction, + tablePos + }); + + const handleMove = useCallback(() => { + const success = tableMoveRowColumn({ + editor, + index, + orientation, + direction, + tablePos + }); + if (success) onMoved?.(); + return success; + }, [editor, index, orientation, direction, tablePos, onMoved]); + + const label = useMemo(() => { + const orientationLabels = + tableMoveRowColumnLabels[selectionType?.orientation || 'row']; + return ( + orientationLabels[direction] || + `Move ${selectionType?.orientation} ${direction}` + ); + }, [selectionType, direction]); + + const Icon = useMemo(() => { + return tableMoveRowColumnIcons[direction]; + }, [direction]); + + return { + isVisible, + canMoveRowColumn: canPerformMove, + handleMove, + label, + Icon + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/index.tsx new file mode 100644 index 00000000..af1403d9 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/index.tsx @@ -0,0 +1 @@ +export * from './table-selection-overlay'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/table-selection-overlay.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/table-selection-overlay.tsx new file mode 100644 index 00000000..231cf7e0 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/table-selection-overlay.tsx @@ -0,0 +1,573 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FloatingPortal, useFloating } from '@floating-ui/react'; +import type { Node } from '@tiptap/pm/model'; +import type { EditorState, Selection } from '@tiptap/pm/state'; +import { cellAround, CellSelection } from '@tiptap/pm/tables'; +import type { EditorView } from '@tiptap/pm/view'; +import type { Editor } from '@tiptap/react'; + +// --- Lib --- +import { + domCellAround, + getTable, + rectEq +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { useResizeOverlay } from '@workspace/editor/components/tiptap-node/table-node/ui/table-selection-overlay/use-resize-overlay'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableSelectionOverlayProps { + editor?: Editor | null; + cellMenu?: React.ComponentType<{ + onOpenChange?: (isOpen: boolean) => void; + editor?: Editor | null; + onResizeStart?: (handle: ResizeHandle) => (event: React.MouseEvent) => void; + }>; + showResizeHandles?: boolean; + onMenuOpenChange?: (isOpen: boolean) => void; +} + +// tl = top-left +// tr = top-right +// bl = bottom-left +// br = bottom-right +export type ResizeHandle = 'tl' | 'tr' | 'bl' | 'br' | null; + +// if an element’s edge is within 5px of the selection edge, +// it is treated as aligned. +const CORNER_DETECTION_TOLERANCE = 5; + +const getCellAtCoordinates = ( + state: EditorState, + view: EditorView, + x: number, + y: number +) => { + const pos = view.posAtCoords({ left: x, top: y })?.pos; + if (pos == null) return null; + + const $pos = state.doc.resolve(pos); + return cellAround($pos); +}; + +const getSelectionBoundingRect = ( + view: EditorView, + selection: Selection +): DOMRect | null => { + if (!(selection instanceof CellSelection)) return null; + + const cells: Element[] = []; + selection.forEachCell((_node: Node, pos: number) => { + const dom = view.nodeDOM(pos) as Element | null; + if (dom) cells.push(dom); + }); + + if (cells.length === 0) return null; + + const bounds = { + left: Infinity, + top: Infinity, + right: -Infinity, + bottom: -Infinity + }; + + cells.forEach((cell) => { + const rect = cell.getBoundingClientRect(); + bounds.left = Math.min(bounds.left, rect.left); + bounds.top = Math.min(bounds.top, rect.top); + bounds.right = Math.max(bounds.right, rect.right); + bounds.bottom = Math.max(bounds.bottom, rect.bottom); + }); + + return new DOMRect( + bounds.left, + bounds.top, + bounds.right - bounds.left, + bounds.bottom - bounds.top + ); +}; + +const getSingleCellBoundingRect = ( + view: EditorView, + cellPos: number +): DOMRect | null => { + const cellDom = view.nodeDOM(cellPos) as Element | null; + if (!cellDom) return null; + + const rect = cellDom.getBoundingClientRect(); + return new DOMRect(rect.left, rect.top, rect.width, rect.height); +}; + +const createVirtualReference = (rect: DOMRect) => ({ + getBoundingClientRect: () => rect +}); + +interface CornerPositions { + topLeft: number | null; + topRight: number | null; + bottomLeft: number | null; + bottomRight: number | null; +} + +const findCornerCells = ( + view: EditorView, + selection: CellSelection, + selectionRect: DOMRect +): CornerPositions => { + const corners: CornerPositions = { + topLeft: null, + topRight: null, + bottomLeft: null, + bottomRight: null + }; + + // It takes two numbers: value1 and value2. + // It calculates the absolute difference between them: Math.abs(value1 - value2). + // It checks whether that difference is less than 5 (CORNER_DETECTION_TOLERANCE). + // It returns a boolean: + // true → if value1 and value2 are within 5 (CORNER_DETECTION_TOLERANCE) of each other. + // false → if they are 5 or more units apart. + const isNearEdge = (value1: number, value2: number) => + Math.abs(value1 - value2) < CORNER_DETECTION_TOLERANCE; + + selection.forEachCell((_node: Node, pos: number) => { + const dom = view.nodeDOM(pos) as Element | null; + if (!dom) return; + + const cellRect = dom.getBoundingClientRect(); + + // Top-left corner + if ( + isNearEdge(cellRect.left, selectionRect.left) && + isNearEdge(cellRect.top, selectionRect.top) + ) { + corners.topLeft = pos; + } + + // Top-right corner + if ( + isNearEdge(cellRect.right, selectionRect.right) && + isNearEdge(cellRect.top, selectionRect.top) + ) { + corners.topRight = pos; + } + + // Bottom-left corner + if ( + isNearEdge(cellRect.left, selectionRect.left) && + isNearEdge(cellRect.bottom, selectionRect.bottom) + ) { + corners.bottomLeft = pos; + } + + // Bottom-right corner + if ( + isNearEdge(cellRect.right, selectionRect.right) && + isNearEdge(cellRect.bottom, selectionRect.bottom) + ) { + corners.bottomRight = pos; + } + }); + + return corners; +}; + +const getAnchorCellForHandle = ( + view: EditorView, + selection: CellSelection, + selectionRect: DOMRect, + handle: ResizeHandle +): { pos: number } | null => { + if (!handle) return null; + + const corners = findCornerCells(view, selection, selectionRect); + + const anchorMap: Record, keyof CornerPositions> = { + tl: 'bottomRight', + tr: 'bottomLeft', + bl: 'topRight', + br: 'topLeft' + }; + + const anchorPos = corners[anchorMap[handle]]; + return anchorPos ? { pos: anchorPos } : null; +}; + +const createHandleStyles = (): React.CSSProperties => ({ + position: 'absolute', + width: 15, + height: 15, + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + pointerEvents: 'auto', + zIndex: 10 +}); + +const createCornerHandleStyles = ( + position: 'tl' | 'tr' | 'bl' | 'br', + isActiveHandle: boolean, + isDisabled: boolean = false +): React.CSSProperties => { + const baseStyles = createHandleStyles(); + + const positionStyles: Record = { + tl: { + top: -7.5, + left: -7.5, + cursor: isDisabled ? 'default' : 'nwse-resize' + }, + tr: { + top: -7.5, + right: -7.5, + cursor: isDisabled ? 'default' : 'nesw-resize' + }, + bl: { + bottom: -7.5, + left: -7.5, + cursor: isDisabled ? 'default' : 'nesw-resize' + }, + br: { + bottom: -7.5, + right: -7.5, + cursor: isDisabled ? 'default' : 'nwse-resize' + } + }; + + return { + ...baseStyles, + ...positionStyles[position], + opacity: isDisabled ? 0.3 : isActiveHandle ? 1 : 0.5, + pointerEvents: isDisabled ? 'none' : 'auto' + }; +}; + +export const TableSelectionOverlay: React.FC = ({ + editor: providedEditor, + cellMenu: CellMenu, + showResizeHandles = true, + onMenuOpenChange +}) => { + const { editor } = useTiptapEditor(providedEditor); + const [isVisible, setIsVisible] = useState(true); + const [selectionRect, setSelectionRect] = useState(null); + const [activeHandle, setActiveHandle] = useState(null); + const [tableDom, setTableDom] = useState(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const anchorCellRef = useRef(null); + const activeHandleRef = useRef(null); + const containerRef = useRef(null); + + const { refs, floatingStyles, update } = useFloating({ + placement: 'top-start' + }); + + useEffect(() => { + if (selectionRect) { + const virtualReference = createVirtualReference(selectionRect); + refs.setPositionReference(virtualReference); + } + }, [selectionRect, refs]); + + const updateSelectionRect = useCallback(() => { + if (!editor) return; + + const { selection } = editor.state; + + if (selection instanceof CellSelection) { + const rect = getSelectionBoundingRect(editor.view, selection); + + if (!rect) { + setIsVisible(false); + setSelectionRect((prev) => (prev ? null : prev)); + return; + } + + setSelectionRect((prev) => (rectEq(prev, rect) ? prev : rect)); + setIsVisible(true); + return; + } + + // single cell handling + const { $anchor } = selection; + const cell = cellAround($anchor); + + if (cell) { + const rect = getSingleCellBoundingRect(editor.view, cell.pos); + + if (rect) { + setSelectionRect((prev) => (rectEq(prev, rect) ? prev : rect)); + setIsVisible(true); + return; + } + } + + setIsVisible(false); + setSelectionRect((prev) => (prev ? null : prev)); + }, [editor]); + + useResizeOverlay(editor, updateSelectionRect); + + useEffect(() => { + if (update && selectionRect) { + update(); + } + }, [update, selectionRect]); + + const createResizeHandler = useCallback( + (handle: ResizeHandle) => (event: React.MouseEvent) => { + if ( + !editor || + !handle || + !selectionRect || + isMenuOpen || + !showResizeHandles + ) + return; + + event.preventDefault(); + event.stopPropagation(); + + const { selection } = editor.state; + let cellSelection: CellSelection | null = null; + + if (selection instanceof CellSelection) { + cellSelection = selection; + } else { + const { $anchor } = selection; + const cell = cellAround($anchor); + + if (cell) { + try { + cellSelection = CellSelection.create( + editor.state.doc, + cell.pos, + cell.pos + ); + } catch (error) { + console.warn( + 'Could not create single cell selection for resize:', + error + ); + return; + } + } + } + + if (!cellSelection) return; + + const anchorCell = getAnchorCellForHandle( + editor.view, + cellSelection, + selectionRect, + handle + ); + if (!anchorCell) return; + + setActiveHandle(handle); + activeHandleRef.current = handle; + anchorCellRef.current = anchorCell.pos; + + const handleMouseMove = (mouseEvent: MouseEvent) => { + if (!editor || anchorCellRef.current == null) return; + + const target = domCellAround(mouseEvent.target as Element); + if (!target || target.type !== 'cell') return; + + const targetCell = getCellAtCoordinates( + editor.state, + editor.view, + mouseEvent.clientX, + mouseEvent.clientY + ); + if (!targetCell) return; + + try { + const newSelection = CellSelection.create( + editor.state.doc, + anchorCellRef.current, + targetCell.pos + ); + + const transaction = editor.state.tr.setSelection(newSelection); + editor.view.dispatch(transaction); + } catch (error) { + console.debug('Invalid cell selection during resize:', error); + } + }; + + const handleMouseUp = () => { + setActiveHandle(null); + activeHandleRef.current = null; + anchorCellRef.current = null; + + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + }, + [editor, selectionRect, isMenuOpen, showResizeHandles] + ); + + const updateTableDom = useCallback(() => { + if (!editor) { + setTableDom(null); + return; + } + + const table = getTable(editor); + if (!table) { + setTableDom(null); + return; + } + + setTableDom((prev) => { + const currentDom = prev; + const newDom = editor.view.nodeDOM(table.pos) as HTMLElement | null; + return currentDom === newDom ? currentDom : newDom; + }); + }, [editor]); + + const handleMenuOpenChange = useCallback( + (isOpen: boolean) => { + setIsMenuOpen(isOpen); + onMenuOpenChange?.(isOpen); + }, + [onMenuOpenChange] + ); + + useEffect(() => { + if (!editor) return; + + const handleSelectionUpdate = () => { + updateSelectionRect(); + updateTableDom(); + }; + + editor.on('selectionUpdate', handleSelectionUpdate); + updateSelectionRect(); + updateTableDom(); + + return () => { + editor.off('selectionUpdate', handleSelectionUpdate); + }; + }, [editor, updateSelectionRect, updateTableDom]); + + useEffect(() => { + const c = tableDom?.querySelector( + '.table-selection-overlay-container' + ) as HTMLElement | null; + containerRef.current = c ?? null; + }, [tableDom]); + + if (!isVisible || !selectionRect) { + return null; + } + + if (!editor) return null; + + const renderCellMenu = () => { + if (!CellMenu) return null; + + return ( + e.stopPropagation()} + style={{ pointerEvents: 'auto' }} + > + + + ); + }; + + return ( + +
+
+
+ +
+ {/* Menu Component */} + {renderCellMenu()} + + {/* Corner resize handles */} + {showResizeHandles && ( + <> +
+
+
+
+ + )} +
+
+
+ + ); +}; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/use-resize-overlay.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/use-resize-overlay.ts new file mode 100644 index 00000000..73a63c1b --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-selection-overlay/use-resize-overlay.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { Transaction } from '@tiptap/pm/state'; +import { columnResizingPluginKey } from '@tiptap/pm/tables'; +import type { Editor } from '@tiptap/react'; + +export function useResizeOverlay( + editor: Editor | null, + updateSelectionRect: () => void +) { + const rafId = useRef(null); + + const stopLoop = useCallback(() => { + if (rafId.current != null) { + cancelAnimationFrame(rafId.current); + rafId.current = null; + } + }, []); + + const startLoop = useCallback(() => { + if (rafId.current != null) return; + const tick = () => { + const st = columnResizingPluginKey.getState(editor!.state); + const dragging = !!st?.dragging; + updateSelectionRect(); // mutate overlay styles; avoid setState if possible + if (dragging) { + rafId.current = requestAnimationFrame(tick); + } else { + stopLoop(); + // one final sync after mouseup + updateSelectionRect(); + } + }; + rafId.current = requestAnimationFrame(tick); + }, [editor, updateSelectionRect, stopLoop]); + + useEffect(() => { + if (!editor) return; + + const onTx = ({ transaction }: { transaction: Transaction }) => { + // this is for non-resize txs that may affect selection + updateSelectionRect(); + + const meta = transaction.getMeta(columnResizingPluginKey); + if (!meta) return; + + // drag start + if ( + Object.prototype.hasOwnProperty.call(meta, 'setDragging') && + meta.setDragging + ) { + startLoop(); + } + + // drag end is also a tx with setDragging: null — rAF loop will notice and stop itself + if ( + Object.prototype.hasOwnProperty.call(meta, 'setDragging') && + meta.setDragging == null + ) { + // if loop missed it for any reason, force a stop + final sync + stopLoop(); + updateSelectionRect(); + } + + // handle-only hover (optional): update once for cursor changes, etc. + if (Object.prototype.hasOwnProperty.call(meta, 'setHandle')) { + updateSelectionRect(); + } + }; + + editor.on('transaction', onTx); + return () => { + editor.off('transaction', onTx); + stopLoop(); + }; + }, [editor, startLoop, stopLoop, updateSelectionRect]); +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/index.tsx new file mode 100644 index 00000000..9a61f044 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/index.tsx @@ -0,0 +1,2 @@ +export * from './table-sort-row-column-button'; +export * from './use-table-sort-row-column'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/table-sort-row-column-button.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/table-sort-row-column-button.tsx new file mode 100644 index 00000000..0e6950a1 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/table-sort-row-column-button.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { forwardRef, useCallback } from 'react'; + +// --- Tiptap UI --- +import type { UseTableSortRowColumnConfig } from '@workspace/editor/components/tiptap-node/table-node/ui/table-sort-row-column-button'; +import { useTableSortRowColumn } from '@workspace/editor/components/tiptap-node/table-node/ui/table-sort-row-column-button'; +// --- UI Primitives --- +import type { ButtonProps } from '@workspace/editor/components/tiptap-ui-primitive/button'; +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; + +export interface TableSortRowColumnButtonProps + extends Omit, UseTableSortRowColumnConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string; +} + +/** + * Button component for sorting a table row/column in a Tiptap editor. + * + * For custom button implementations, use the `useTableSortRowColumn` hook instead. + */ +export const TableSortRowColumnButton = forwardRef< + HTMLButtonElement, + TableSortRowColumnButtonProps +>( + ( + { + editor: providedEditor, + index, + orientation, + direction, + hideWhenUnavailable = false, + onSorted, + text, + onClick, + children, + ...buttonProps + }, + ref + ) => { + const { editor } = useTiptapEditor(providedEditor); + const { isVisible, handleSort, label, canSortRowColumn, Icon } = + useTableSortRowColumn({ + editor, + index, + orientation, + direction, + hideWhenUnavailable, + onSorted + }); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + handleSort(); + }, + [handleSort, onClick] + ); + + if (!isVisible) { + return null; + } + + return ( + + ); + } +); + +TableSortRowColumnButton.displayName = 'TableSortRowColumnButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/use-table-sort-row-column.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/use-table-sort-row-column.ts new file mode 100644 index 00000000..351fb985 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-sort-row-column-button/use-table-sort-row-column.ts @@ -0,0 +1,476 @@ +import { useCallback, useMemo } from 'react'; +import type { Node } from '@tiptap/pm/model'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { ArrowDownAZIcon } from '@workspace/editor/components/tiptap-icons/arrow-down-a-z-icon'; +import { ArrowDownZAIcon } from '@workspace/editor/components/tiptap-icons/arrow-down-z-a-icon'; +import type { Orientation } from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +import { + getRowOrColumnCells, + getTable, + getTableSelectionType, + isCellEmpty, + type CellInfo +} from '@workspace/editor/components/tiptap-node/table-node/lib/tiptap-table-utils'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +export type SortDirection = 'asc' | 'desc'; + +export interface UseTableSortRowColumnConfig { + /** + * The Tiptap editor instance. If omitted, the hook will use + * the context/editor from `useTiptapEditor`. + */ + editor?: Editor | null; + /** + * The index of the row or column to sort. + * If omitted, will use the current selection. + */ + index?: number; + /** + * Whether you're sorting a row or a column. + * If omitted, will use the current selection. + */ + orientation?: Orientation; + /** + * The position of the table in the document. + */ + tablePos?: number; + /** + * The sort direction (ascending or descending). + */ + direction: SortDirection; + /** + * Hide the button when sorting isn't currently possible. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Callback function called after a successful sort. + */ + onSorted?: () => void; +} + +const REQUIRED_EXTENSIONS = ['tableHandleExtension']; + +export const tableSortRowColumnLabels: Record< + Orientation, + Record +> = { + row: { + asc: 'Sort row A-Z', + desc: 'Sort row Z-A' + }, + column: { + asc: 'Sort column A-Z', + desc: 'Sort column Z-A' + } +}; + +export const tableSortRowColumnIcons = { + asc: ArrowDownAZIcon, + desc: ArrowDownZAIcon +}; + +/** + * Check if a specific cell is a header cell + */ +function isCellHeader(cellNode: Node | null): boolean { + if (!cellNode) return false; + + return ( + cellNode.type.name === 'tableHeader' || + cellNode.type.name === 'table_header' || + cellNode.attrs?.header === true + ); +} + +/** + * Extract text content from a cell node for sorting comparison + */ +function getCellSortText(cellNode: Node | null): string { + if (!cellNode) return ''; + + let text = ''; + cellNode.descendants((node) => { + if (node.isText) { + text += node.text || ''; + } + return true; + }); + + return text.trim().toLowerCase(); +} + +/** + * Create a sortable item with all necessary data for restoration + */ +interface SortableCell { + sortText: string; + originalNode: Node | null; + cellInfo: CellInfo; + originalIndex: number; + isHeader: boolean; + isEmpty: boolean; +} + +/** + * Checks if a table row/column sort can be performed + * in the current editor state. + */ +function canSortRowColumn({ + editor, + index, + orientation, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + tablePos?: number; +}): boolean { + if ( + !editor || + !editor.isEditable || + !isExtensionAvailable(editor, REQUIRED_EXTENSIONS) + ) { + return false; + } + + try { + const table = getTable(editor, tablePos); + if (!table) return false; + + const cellData = getRowOrColumnCells(editor, index, orientation, tablePos); + + // Need at least 2 items to sort + if (cellData.orientation === 'row') { + if (table.map.width < 2) return false; + } else { + if (table.map.height < 2) return false; + } + + if (cellData.mergedCells.length > 0) { + return false; + } + + // Check if there's actual content to sort (excluding headers) + const hasContent = cellData.cells.some( + (cellInfo) => + cellInfo.node && + !isCellHeader(cellInfo.node) && + !isCellEmpty(cellInfo.node) + ); + + if (!hasContent) { + return false; + } + + return true; + } catch { + return false; + } +} + +/** + * Executes the row/column sort in the editor while preserving marks and attributes. + * Header cells are excluded from sorting and remain in their original positions. + * Empty cells are always sorted to the end. + */ +function tableSortRowColumn({ + editor, + index, + orientation, + direction, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + direction: SortDirection; + tablePos?: number; +}): boolean { + if (!canSortRowColumn({ editor, index, orientation, tablePos }) || !editor) + return false; + + try { + const { state, view } = editor; + const tr = state.tr; + + const cellData = getRowOrColumnCells(editor, index, orientation, tablePos); + + if (cellData.mergedCells.length > 0) { + console.warn( + `Cannot sort ${orientation} ${index}: contains merged cells` + ); + return false; + } + + if (cellData.cells.length < 2) { + return false; + } + + // Create sortable items, marking headers, data cells, and empty cells + const allItems: SortableCell[] = cellData.cells.map( + (cellInfo, originalIndex) => { + const isHeader = isCellHeader(cellInfo.node); + const isEmpty = cellInfo.node ? isCellEmpty(cellInfo.node) : true; + return { + sortText: getCellSortText(cellInfo.node), + originalNode: cellInfo.node, + cellInfo, + originalIndex, + isHeader, + isEmpty + }; + } + ); + + const dataItems = allItems.filter((item) => !item.isHeader); + + if (dataItems.length < 2) { + console.log('No sortable data cells found (excluding headers)'); + return false; + } + + // Sort data items with special handling for empty cells + dataItems.sort((a, b) => { + // Empty cells always go to the end, regardless of sort direction + if (a.isEmpty && !b.isEmpty) return 1; + if (!a.isEmpty && b.isEmpty) return -1; + if (a.isEmpty && b.isEmpty) return 0; + + // For non-empty cells, sort normally + const comparison = a.sortText.localeCompare(b.sortText, undefined, { + sensitivity: 'base' + }); + return direction === 'asc' ? comparison : -comparison; + }); + + const newCellNodes: Node[] = []; + let dataIndex = 0; + + for (let i = 0; i < allItems.length; i++) { + const originalItem = allItems[i]; + const targetCell = cellData.cells[i]; + + if (!targetCell || !originalItem) continue; + + let nodeToPlace: Node | null = null; + + if (originalItem.isHeader) { + // Keep header in its original position + nodeToPlace = originalItem.originalNode; + } else { + // Use the next sorted data cell + const sortedDataItem = dataItems[dataIndex]; + nodeToPlace = sortedDataItem?.originalNode || null; + dataIndex++; + } + + if (nodeToPlace && targetCell.node) { + const cellType = targetCell.node.type; + const newCellNode = cellType.create( + nodeToPlace.attrs, + nodeToPlace.content, + nodeToPlace.marks + ); + newCellNodes.push(newCellNode); + } else { + newCellNodes.push(targetCell.node!); + } + } + + // Replace each cell with the new cell (headers preserved, data sorted) + // We need to go in reverse order to maintain correct positions + const cellsToReplace = [...cellData.cells].reverse(); + const newNodesToPlace = [...newCellNodes].reverse(); + + cellsToReplace.forEach((targetCell, reverseIndex) => { + const newNode = newNodesToPlace[reverseIndex]; + if (newNode && targetCell.node) { + // Replace the entire cell node + const cellEnd = targetCell.pos + targetCell.node.nodeSize; + tr.replaceWith(targetCell.pos, cellEnd, newNode); + } + }); + + if (tr.docChanged) { + view.dispatch(tr); + return true; + } + + return false; + } catch (error) { + console.error(`Error sorting table ${orientation}:`, error); + return false; + } +} + +/** + * Determines if the sort button should be shown + * based on editor state and config. + */ +function shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable, + tablePos +}: { + editor: Editor | null; + index?: number; + orientation?: Orientation; + hideWhenUnavailable: boolean; + tablePos: number | undefined; +}): boolean { + if (!editor || !editor.isEditable) return false; + if (!isExtensionAvailable(editor, REQUIRED_EXTENSIONS)) return false; + + const table = getTable(editor, tablePos); + if (!table) return false; + + const selectionType = getTableSelectionType( + editor, + index, + orientation, + tablePos + ); + if (!selectionType) return false; + + return hideWhenUnavailable + ? canSortRowColumn({ editor, index, orientation, tablePos }) + : true; +} + +/** + * Custom hook that provides **table row/column sorting** + * functionality for the Tiptap editor. + * + * **Header Handling:** Header cells are automatically detected and excluded + * from sorting. During a sort operation, header cells remain in their original + * positions while only data cells are rearranged. Headers are identified by + * node type (`tableHeader`) or attributes (`header: true`). + * + * **Empty Cell Handling:** Empty cells are always sorted to the end, + * regardless of sort direction (A-Z or Z-A). + * + * @example + * ```tsx + * // Sort currently selected row/column (smart mode) + * function SortButton() { + * const { isVisible, handleSort } = useTableSortRowColumn({ direction: "asc" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Sort specific row, headers will be preserved + * function SortRowButton({ rowIndex }: { rowIndex: number }) { + * const { isVisible, handleSort, label, canSortRowColumn } = useTableSortRowColumn({ + * index: rowIndex, + * orientation: "row", + * direction: "asc", + * hideWhenUnavailable: true, + * onSorted: () => console.log("Row sorted! Headers stayed in place."), + * }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Sort with callback to handle the result + * function SmartSortButton() { + * const { isVisible, handleSort, label } = useTableSortRowColumn({ + * direction: "desc", + * hideWhenUnavailable: true, + * onSorted: () => { + * console.log("Sort completed! Headers were automatically preserved.") + * } + * }) + * + * if (!isVisible) return null + * + * return + * } + * ``` + */ +export function useTableSortRowColumn( + config: UseTableSortRowColumnConfig = { direction: 'asc' } +) { + const { + editor: providedEditor, + index, + orientation, + tablePos, + direction, + hideWhenUnavailable = false, + onSorted + } = config; + + const { editor } = useTiptapEditor(providedEditor); + + const selectionType = getTableSelectionType(editor, index, orientation); + + const isVisible = shouldShowButton({ + editor, + index, + orientation, + hideWhenUnavailable, + tablePos + }); + + const canPerformSort = canSortRowColumn({ + editor, + index, + orientation, + tablePos + }); + + const handleSort = useCallback(() => { + const success = tableSortRowColumn({ + editor, + index, + orientation, + direction, + tablePos + }); + if (success) onSorted?.(); + return success; + }, [editor, index, orientation, direction, tablePos, onSorted]); + + const label = useMemo(() => { + const orientationLabels = + tableSortRowColumnLabels[selectionType?.orientation || 'row']; + return ( + orientationLabels[direction] || + `Sort ${selectionType?.orientation} ${direction}` + ); + }, [selectionType, direction]); + + const Icon = useMemo(() => { + return tableSortRowColumnIcons[direction] || ArrowDownAZIcon; + }, [direction]); + + return { + isVisible, + canSortRowColumn: canPerformSort, + handleSort, + label, + Icon + }; +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/index.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/index.tsx new file mode 100644 index 00000000..7286740d --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/index.tsx @@ -0,0 +1,3 @@ +export * from './table-grid-selector'; +export * from './table-trigger-button'; +export * from './use-table-trigger'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/table-grid-selector.scss b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/table-grid-selector.scss new file mode 100644 index 00000000..0f0d604e --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/table-grid-selector.scss @@ -0,0 +1,98 @@ +:root { + --tt-bg: var(--white); + --tt-selected-bg-color: var(--tt-brand-color-50); + --tt-selected-border-color: var(--tt-brand-color-400); + + // Cell styling + --tt-cell-bg-color: var(--tt-gray-light-50); + --tt-cell-border-color: var(--tt-gray-light-200); + + // Indicator styling + --tt-indicator-item-border-color: var(--tt-gray-light-a-200); + --tt-indicator-icon-color: var(--tt-gray-light-a-400); + --tt-indicator-delimiter-color: var(--tt-gray-light-a-500); +} + +.dark { + --tt-bg: var(--black); + --tt-selected-bg-color: var(--tt-brand-color-900); + + // Cell styling - reuse light border color + --tt-cell-bg-color: var(--tt-gray-dark-100); + --tt-cell-border-color: var(--tt-gray-dark-200); + + // Indicator styling + --tt-indicator-item-border-color: var(--tt-gray-dark-a-200); + --tt-indicator-icon-color: var(--tt-gray-dark-a-400); + --tt-indicator-delimiter-color: var(--tt-gray-dark-a-500); +} + +// Grid container +.tiptap-table-grid { + display: grid; + gap: 0.25rem; + padding: 0.25rem; + grid-template-columns: repeat(var(--tt-table-columns), 1rem); +} + +// Grid cell +.tiptap-button[data-size="small"].tiptap-table-grid-cell { + width: 1rem; + height: 1rem; + min-width: 1rem; + padding: 0; + border: 1px solid var(--tt-cell-border-color); + border-radius: var(--tt-radius-xs); + background-color: var(--tt-cell-bg-color); + cursor: pointer; + transition: all 150ms ease; + + &.selected { + background-color: var(--tt-selected-bg-color); + border-color: var(--tt-selected-border-color); + } +} + +// Size indicator +.tiptap-table-size-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + width: 100%; + margin-top: 0.25rem; +} + +.tiptap-table-size-indicator-item { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 0.75rem; + min-height: 2rem; + padding: 0.5rem; + border: 1px solid var(--tt-indicator-item-border-color); + border-radius: var(--tt-radius-xl); + background: var(--tt-bg); + + .tiptap-table-column-icon, + .tiptap-table-row-icon { + width: 1rem; + height: 1rem; + color: var(--tt-indicator-icon-color); + } +} + +.tiptap-table-size-indicator-text { + width: 1.781rem; + font-size: 0.875rem; + font-weight: 400; + text-align: center; +} + +.tiptap-table-size-indicator-delimiter { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 500; + color: var(--tt-indicator-delimiter-color); +} diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/table-grid-selector.tsx b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/table-grid-selector.tsx new file mode 100644 index 00000000..cc802112 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/table-grid-selector.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { forwardRef, useCallback, useMemo } from 'react'; + +// --- Icons --- +import { TableColumnIcon } from '@workspace/editor/components/tiptap-icons/table-column-icon'; +import { TableRowIcon } from '@workspace/editor/components/tiptap-icons/table-row-icon'; +// --- UI Primitives --- +import { Button } from '@workspace/editor/components/tiptap-ui-primitive/button'; +// --- Lib --- +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +import './table-grid-selector.scss'; + +// --- Types --- +export interface CellCoordinates { + row: number; + col: number; +} + +export interface TableGridSelectorProps { + /** + * Initial number of rows to display in the grid + * @default 8 + */ + maxRows?: number; + /** + * Initial number of columns to display in the grid + * @default 8 + */ + maxCols?: number; + /** + * Currently hovered cell coordinates + */ + hoveredCell: CellCoordinates | null; + /** + * Callback when a cell is hovered + */ + onCellHover: (row: number, col: number) => void; + /** + * Callback when a cell is clicked + */ + onCellClick: (row: number, col: number) => void; + /** + * Callback when mouse leaves the grid + */ + onMouseLeave?: () => void; + /** + * Whether the grid cells should be disabled + * @default false + */ + disabled?: boolean; + /** + * Additional class name for the container + */ + className?: string; + /** + * Whether to show the size indicator + * @default true + */ + showSizeIndicator?: boolean; +} + +interface GridCellProps { + row: number; + col: number; + isSelected: boolean; + disabled: boolean; + onMouseEnter: () => void; + onClick: () => void; +} + +const isCellSelected = ( + cell: CellCoordinates, + hoveredCell: CellCoordinates | null +): boolean => { + if (!hoveredCell) return false; + return cell.row <= hoveredCell.row && cell.col <= hoveredCell.col; +}; + +const generateGridCells = (rows: number, cols: number): CellCoordinates[] => { + const totalCells = rows * cols; + return Array.from({ length: totalCells }, (_, index) => ({ + row: Math.floor(index / cols), + col: index % cols + })); +}; + +const GridCell = ({ + row, + col, + isSelected, + disabled, + onMouseEnter, + onClick +}: GridCellProps) => ( + + + + + + + + + + + ); + } +); + +TableTriggerButton.displayName = 'TableTriggerButton'; diff --git a/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/use-table-trigger.ts b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/use-table-trigger.ts new file mode 100644 index 00000000..2d1dd007 --- /dev/null +++ b/packages/editor/src/components/tiptap-node/table-node/ui/table-trigger-button/use-table-trigger.ts @@ -0,0 +1,177 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import type { Editor } from '@tiptap/react'; + +// --- Icons --- +import { TableIcon } from '@workspace/editor/components/tiptap-icons/table-icon'; +// --- Hooks --- +import { useTiptapEditor } from '@workspace/editor/hooks/use-tiptap-editor'; +// --- Lib --- +import { isExtensionAvailable } from '@workspace/editor/lib/tiptap-utils'; + +const REQUIRED_EXTENSIONS = ['table']; + +/** + * Configuration for the table trigger functionality + */ +export interface UseTableTriggerButtonConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null; + /** + * Whether the button should hide when table insertion is not available. + * @default false + */ + hideWhenUnavailable?: boolean; + /** + * Maximum number of rows in the grid selector. + * @default 8 + */ + maxRows?: number; + /** + * Maximum number of columns in the grid selector. + * @default 8 + */ + maxCols?: number; + /** + * Callback function called after a successful table insertion. + */ + onInserted?: (rows: number, cols: number) => void; +} + +/** + * Checks if a table can be inserted in the current editor state + */ +export function canInsertTable(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false; + return isExtensionAvailable(editor, REQUIRED_EXTENSIONS); +} + +/** + * Inserts a table with the specified dimensions + */ +export function insertTable( + editor: Editor | null, + rows: number, + cols: number +): boolean { + if (!editor || !canInsertTable(editor)) return false; + + try { + return editor + .chain() + .focus() + .insertTable({ + rows, + cols, + withHeaderRow: false + }) + .run(); + } catch (error) { + console.error('Error inserting table:', error); + return false; + } +} + +/** + * Determines if the table trigger button should be shown + */ +export function shouldShowButton( + editor: Editor | null, + hideWhenUnavailable: boolean +): boolean { + if (!editor || !editor.isEditable) return false; + + const hasExtension = isExtensionAvailable(editor, REQUIRED_EXTENSIONS); + if (!hasExtension) return false; + + // If hiding when unavailable, also check if we can actually insert + return !hideWhenUnavailable || canInsertTable(editor); +} + +/** + * Custom hook that provides table insertion functionality for Tiptap editor + * + * @example + * ```tsx + * function MyTableButton() { + * const { + * isVisible, + * canInsert, + * isOpen, + * setIsOpen, + * hoveredCell, + * handleCellHover, + * handleCellClick, + * resetHoveredCell + * } = useTableTriggerButton({ editor }) + * + * if (!isVisible) return null + * + * return ( + * + * Insert Table + * + * + * + * + * ) + * } + * ``` + */ +export function useTableTriggerButton(config?: UseTableTriggerButtonConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onInserted + } = config || {}; + + const { editor } = useTiptapEditor(providedEditor); + const [isOpen, setIsOpen] = useState(false); + const [hoveredCell, setHoveredCell] = useState<{ + row: number; + col: number; + } | null>(null); + + const isVisible = shouldShowButton(editor, hideWhenUnavailable); + const canInsert = canInsertTable(editor); + + const handleCellHover = useCallback((row: number, col: number) => { + setHoveredCell({ row, col }); + }, []); + + const handleCellClick = useCallback( + (row: number, col: number) => { + const success = insertTable(editor, row + 1, col + 1); + if (success) { + setIsOpen(false); + onInserted?.(row + 1, col + 1); + } + }, + [editor, onInserted] + ); + + const resetHoveredCell = useCallback(() => { + setHoveredCell(null); + }, []); + + return { + isVisible, + canInsert, + isOpen, + setIsOpen, + hoveredCell, + handleCellHover, + handleCellClick, + resetHoveredCell, + label: 'Insert table', + Icon: TableIcon + }; +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/avatar/avatar.scss b/packages/editor/src/components/tiptap-ui-primitive/avatar/avatar.scss new file mode 100644 index 00000000..c90ba057 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/avatar/avatar.scss @@ -0,0 +1,118 @@ +:root { + --tiptap-avatar-border-color: var(--white); + --tiptap-avatar-fallback-bg-color: var(--tt-gray-light-200); + --tiptap-avatar-item-bg-color: var(--tt-gray-light-200); + --tiptap-avatar-fallback-text-color: var(--tt-gray-light-a-600); +} + +.dark { + --tiptap-avatar-border-color: var(--tt-gray-dark-200); + --tiptap-avatar-fallback-bg-color: var(--tt-gray-dark-200); + --tiptap-avatar-item-bg-color: var(--tt-gray-dark-300); + --tiptap-avatar-fallback-text-color: var(--tt-gray-dark-a-600); +} + +.tiptap-avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + overflow: hidden; + user-select: none; + flex-shrink: 0; + + &[data-size="default"] { + width: 1.5rem; + height: 1.5rem; + + .tiptap-avatar-fallback { + font-size: 0.5rem; + } + } + + &[data-size="sm"] { + width: 1.25rem; + height: 1.25rem; + + .tiptap-avatar-fallback { + font-size: 0.4375rem; + } + } + + &[data-size="lg"] { + width: 1.75rem; + height: 1.75rem; + + .tiptap-avatar-fallback { + font-size: 0.625rem; + } + } + + &[data-size="xl"] { + width: 2.25rem; + height: 2.25rem; + + .tiptap-avatar-fallback { + font-size: 0.75rem; + } + } +} + +.tiptap-avatar-item { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + background-color: var( + --dynamic-user-color, + var(--tiptap-avatar-item-bg-color) + ); + border-radius: 50%; +} + +.tiptap-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.tiptap-avatar-fallback { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--tiptap-avatar-fallback-text-color); + border-radius: 50%; + font-weight: 600; +} + +.tiptap-avatar-bg { + position: absolute; + inset: 0; + background-color: var( + --dynamic-user-color, + var(--tiptap-avatar-item-bg-color) + ); + border-radius: 50%; +} + +.tiptap-avatar-group { + display: inline-flex; + align-items: center; + + .tiptap-avatar-image, + .tiptap-avatar-fallback { + border: 2px solid var(--tiptap-avatar-border-color); + } + + .tiptap-avatar:not(:first-child) { + margin-left: -0.5rem; + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/avatar/avatar.tsx b/packages/editor/src/components/tiptap-ui-primitive/avatar/avatar.tsx new file mode 100644 index 00000000..888b28e1 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/avatar/avatar.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { + Children, + createContext, + forwardRef, + isValidElement, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useState +} from 'react'; + +import '@workspace/editor/components/tiptap-ui-primitive/avatar/avatar.scss'; + +type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; +type Size = 'default' | 'sm' | 'lg' | 'xl'; + +interface AvatarContextValue { + imageLoadingStatus: ImageLoadingStatus; + onImageLoadingStatusChange: (status: ImageLoadingStatus) => void; + size: Size; +} + +interface AvatarProps extends React.HTMLAttributes { + size?: Size; + userColor?: string; +} + +interface AvatarImageProps extends Omit< + React.ImgHTMLAttributes, + 'onLoadingStatusChange' | 'src' +> { + src?: string; + onLoadingStatusChange?: (status: ImageLoadingStatus) => void; +} + +interface AvatarFallbackProps extends React.HTMLAttributes { + delayMs?: number; +} + +interface AvatarGroupProps extends React.HTMLAttributes { + maxVisible?: number; + children: React.ReactNode; +} + +const AvatarContext = createContext(undefined); + +const useAvatarContext = () => { + const context = useContext(AvatarContext); + if (!context) { + throw new Error('Avatar components must be used within an Avatar.Root'); + } + return context; +}; + +const useImageLoadingStatus = ( + src?: string, + referrerPolicy?: React.HTMLAttributeReferrerPolicy +): ImageLoadingStatus => { + const initialStatus = !src ? 'error' : 'loading'; + const [loadingStatus, setLoadingStatus] = + useState(initialStatus); + + useLayoutEffect(() => { + if (!src) { + return; + } + + let isMounted = true; + const image = new window.Image(); + + const updateStatus = (status: ImageLoadingStatus) => () => { + if (!isMounted) return; + setLoadingStatus(status); + }; + + image.onload = updateStatus('loaded'); + image.onerror = updateStatus('error'); + image.src = src; + if (referrerPolicy) image.referrerPolicy = referrerPolicy; + + return () => { + isMounted = false; + }; + }, [src, referrerPolicy]); + + return loadingStatus; +}; + +export const Avatar = forwardRef( + ( + { children, className = '', size = 'default', userColor, ...props }, + ref + ) => { + const [imageLoadingStatus, setImageLoadingStatus] = + useState('idle'); + + const onImageLoadingStatusChange = useCallback( + (status: ImageLoadingStatus) => { + setImageLoadingStatus(status); + }, + [] + ); + + const contextValue = useMemo( + () => ({ + imageLoadingStatus, + onImageLoadingStatusChange, + size + }), + [imageLoadingStatus, onImageLoadingStatusChange, size] + ); + + const style = userColor + ? ({ '--dynamic-user-color': userColor } as React.CSSProperties) + : undefined; + + return ( + + + {children} + + + ); + } +); + +Avatar.displayName = 'Avatar'; + +export const AvatarImage = forwardRef( + ({ onLoadingStatusChange, src, className = '', ...props }, ref) => { + const { onImageLoadingStatusChange } = useAvatarContext(); + const imageLoadingStatus = useImageLoadingStatus(src, props.referrerPolicy); + + useLayoutEffect(() => { + if (imageLoadingStatus !== 'idle') { + onLoadingStatusChange?.(imageLoadingStatus); + onImageLoadingStatusChange(imageLoadingStatus); + } + }, [imageLoadingStatus, onLoadingStatusChange, onImageLoadingStatusChange]); + + if (imageLoadingStatus !== 'loaded') return null; + + return ( + + ); + } +); + +AvatarImage.displayName = 'AvatarImage'; + +export const AvatarFallback = forwardRef( + ({ delayMs, className = '', children, ...props }, ref) => { + const context = useAvatarContext(); + const [canRender, setCanRender] = useState(delayMs === undefined); + + useEffect(() => { + if (delayMs !== undefined) { + const timerId = window.setTimeout(() => setCanRender(true), delayMs); + return () => window.clearTimeout(timerId); + } + }, [delayMs]); + + if (!canRender || context.imageLoadingStatus === 'loaded') return null; + + return ( + <> + + + {children} + + + ); + } +); + +AvatarFallback.displayName = 'AvatarFallback'; + +export const AvatarGroup: React.FC = ({ + maxVisible, + children, + className = '', + ...props +}) => { + const childrenArray = Children.toArray(children); + const visibleAvatars = maxVisible + ? childrenArray.slice(0, maxVisible) + : childrenArray; + const remainingCount = childrenArray.length - visibleAvatars.length; + + let avatarProps: AvatarProps = {}; + + Children.forEach(children, (child) => { + if ( + isValidElement(child) && + child.type && + typeof child.type !== 'string' && + (child.type as { displayName?: string }).displayName === 'Avatar' + ) { + avatarProps = { ...avatarProps, ...(child.props as AvatarProps) }; + return; + } + }); + + return ( +
+ {visibleAvatars} + {remainingCount > 0 && ( + + +{remainingCount} + + )} +
+ ); +}; + +AvatarGroup.displayName = 'AvatarGroup'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/avatar/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/avatar/index.tsx new file mode 100644 index 00000000..a7424ca3 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/avatar/index.tsx @@ -0,0 +1 @@ +export * from './avatar'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/badge/badge-colors.scss b/packages/editor/src/components/tiptap-ui-primitive/badge/badge-colors.scss new file mode 100644 index 00000000..8f8a988f --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/badge/badge-colors.scss @@ -0,0 +1,395 @@ +.tiptap-badge { + /************************************************** + Default + **************************************************/ + + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--white); + --tt-badge-bg-color-subdued: var(--white); //less important badge + --tt-badge-bg-color-emphasized: var(--white); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--black); + --tt-badge-bg-color-subdued: var(--black); //less important badge + --tt-badge-bg-color-emphasized: var(--black); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + + /************************************************** + Ghost + **************************************************/ + + &[data-style="ghost"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + } + + /************************************************** + Gray + **************************************************/ + + &[data-style="gray"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-500); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--white); //more important badge + --tt-badge-bg-color: var(--tt-gray-light-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-light-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-light-a-700 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--white); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--black); //more important badge + --tt-badge-bg-color: var(--tt-gray-dark-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-dark-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-dark-a-800 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--black); //more important badge + } + } + + /************************************************** + Green + **************************************************/ + + &[data-style="green"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-green-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-green-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-dec-2); + --tt-badge-text-color: var(--tt-color-green-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-green-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-green-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-base); + --tt-badge-text-color: var(--tt-color-green-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + } + } + + /************************************************** + Yellow + **************************************************/ + + &[data-style="yellow"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-yellow-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-dec-1); + --tt-badge-text-color: var(--tt-color-yellow-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-yellow-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-inc-1); + --tt-badge-text-color: var(--tt-color-yellow-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + } + } + + /************************************************** + Red + **************************************************/ + + &[data-style="red"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-red-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-red-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-dec-2); + --tt-badge-text-color: var(--tt-color-red-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-red-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-red-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-base); + --tt-badge-text-color: var(--tt-color-red-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + } + } + + /************************************************** + Brand + **************************************************/ + + &[data-style="brand"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-brand-color-300); + --tt-badge-border-color-subdued: var(--tt-brand-color-200); + --tt-badge-border-color-emphasized: var(--tt-brand-color-600); + --tt-badge-text-color: var(--tt-brand-color-800); + --tt-badge-text-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-50 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-100); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-800); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-100 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-brand-color-700); + --tt-badge-border-color-subdued: var(--tt-brand-color-800); + --tt-badge-border-color-emphasized: var(--tt-brand-color-400); + --tt-badge-text-color: var(--tt-brand-color-200); + --tt-badge-text-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-950 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-900); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-950 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-200); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-900 + ); //more important badge + } + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/badge/badge-group.scss b/packages/editor/src/components/tiptap-ui-primitive/badge/badge-group.scss new file mode 100644 index 00000000..91bd45b1 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/badge/badge-group.scss @@ -0,0 +1,16 @@ +.tiptap-badge-group { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.tiptap-badge-group { + [data-orientation="vertical"] { + flex-direction: column; + } + + [data-orientation="horizontal"] { + flex-direction: row; + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/badge/badge.scss b/packages/editor/src/components/tiptap-ui-primitive/badge/badge.scss new file mode 100644 index 00000000..b2ca9a88 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/badge/badge.scss @@ -0,0 +1,99 @@ +.tiptap-badge { + font-size: 0.625rem; + font-weight: 700; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 1.25rem; + min-width: 1.25rem; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border: solid 1px; + border-radius: var(--tt-radius-sm, 0.375rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + /* button size large */ + &[data-size="large"] { + font-size: 0.75rem; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.375rem; + border-radius: var(--tt-radius-md, 0.375rem); + } + + /* button size small */ + &[data-size="small"] { + height: 1rem; + min-width: 1rem; + padding: 0.125rem; + border-radius: var(--tt-radius-xs, 0.25rem); + } + + /* trim / expand text of the button */ + .tiptap-badge-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + } + + &[data-text-trim="on"] { + .tiptap-badge-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* standard icon, what is used */ + .tiptap-badge-icon { + pointer-events: none; + flex-shrink: 0; + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-badge-icon { + width: 0.75rem; + height: 0.75rem; + } +} + +/* -------------------------------------------- +----------- BADGE COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-badge { + background-color: var(--tt-badge-bg-color); + border-color: var(--tt-badge-border-color); + color: var(--tt-badge-text-color); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-badge-bg-color-emphasized); + border-color: var(--tt-badge-border-color-emphasized); + color: var(--tt-badge-text-color-emphasized); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-badge-bg-color-subdued); + border-color: var(--tt-badge-border-color-subdued); + color: var(--tt-badge-text-color-subdued); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-subdued); + } + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/badge/badge.tsx b/packages/editor/src/components/tiptap-ui-primitive/badge/badge.tsx new file mode 100644 index 00000000..e44ed373 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/badge/badge.tsx @@ -0,0 +1,45 @@ +import { forwardRef } from 'react'; + +import '@workspace/editor/components/tiptap-ui-primitive/badge/badge-colors.scss'; +import '@workspace/editor/components/tiptap-ui-primitive/badge/badge-group.scss'; +import '@workspace/editor/components/tiptap-ui-primitive/badge/badge.scss'; + +export interface BadgeProps extends React.HTMLAttributes { + variant?: 'ghost' | 'white' | 'gray' | 'green' | 'default'; + size?: 'default' | 'small'; + appearance?: 'default' | 'subdued' | 'emphasized'; + trimText?: boolean; +} + +export const Badge = forwardRef( + ( + { + variant, + size = 'default', + appearance = 'default', + trimText = false, + className, + children, + ...props + }, + ref + ) => { + return ( +
+ {children} +
+ ); + } +); + +Badge.displayName = 'Badge'; + +export default Badge; diff --git a/packages/editor/src/components/tiptap-ui-primitive/badge/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/badge/index.tsx new file mode 100644 index 00000000..1566eee0 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/badge/index.tsx @@ -0,0 +1 @@ +export * from './badge'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/button/button-colors.scss b/packages/editor/src/components/tiptap-ui-primitive/button/button-colors.scss new file mode 100644 index 00000000..fc0dd35e --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/button/button-colors.scss @@ -0,0 +1,429 @@ +.tiptap-button { + /************************************************** + Default button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-gray-light-a-100); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-50); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-gray-dark-a-100); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-50); + } + + /************************************************** + Default button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Default button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Default button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Default button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-light-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-dark-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + + /* ---------------------------------------------------------------- + --------------------------- GHOST BUTTON -------------------------- + ---------------------------------------------------------------- */ + + &[data-style="ghost"] { + /************************************************** + Ghost button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + } + + /************************************************** + Ghost button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Ghost button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-300); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Ghost button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Ghost button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } + + /* ---------------------------------------------------------------- + -------------------------- PRIMARY BUTTON ------------------------- + ---------------------------------------------------------------- */ + + &[data-style="primary"] { + /************************************************** + Primary button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-900); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-900 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-800); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-800 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-600); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-600); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-400); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-500); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-700 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-600 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/button/button-group.scss b/packages/editor/src/components/tiptap-ui-primitive/button/button-group.scss new file mode 100644 index 00000000..59fd2561 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/button/button-group.scss @@ -0,0 +1,22 @@ +.tiptap-button-group { + position: relative; + display: flex; + vertical-align: middle; + + &[data-orientation="vertical"] { + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-width: max-content; + + > .tiptap-button { + width: 100%; + } + } + + &[data-orientation="horizontal"] { + gap: 0.125rem; + flex-direction: row; + align-items: center; + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/button/button.scss b/packages/editor/src/components/tiptap-ui-primitive/button/button.scss new file mode 100644 index 00000000..32d1499b --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/button/button.scss @@ -0,0 +1,314 @@ +.tiptap-button { + font-size: 0.875rem; + font-weight: 500; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 2rem; + min-width: 2rem; + border: none; + padding: 0.5rem; + gap: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--tt-radius-lg, 0.75rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + // focus-visible + &:focus-visible { + outline: none; + } + + &[data-highlighted="true"], + &[data-focus-visible="true"] { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + // outline: 2px solid var(--tt-button-active-icon-color); + } + + &[data-weight="small"] { + width: 1.5rem; + min-width: 1.5rem; + padding-right: 0; + padding-left: 0; + } + + /* button size large */ + &[data-size="large"] { + font-size: 0.9375rem; + height: 2.375rem; + min-width: 2.375rem; + padding: 0.625rem; + } + + /* button size small */ + &[data-size="small"] { + font-size: 0.75rem; + line-height: 1.2; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.3125rem; + border-radius: var(--tt-radius-md, 0.5rem); + } + + /* trim / expand text of the button */ + .tiptap-button-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + line-height: 1.5rem; + } + + &[data-text-trim="on"] { + .tiptap-button-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* global icon settings */ + .tiptap-button-icon, + .tiptap-button-icon-sub, + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + flex-shrink: 0; + } + + /* standard icon, what is used */ + .tiptap-button-icon { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon { + width: 0.875rem; + height: 0.875rem; + } + + /* if 2 icons are used and this icon should be more subtle */ + .tiptap-button-icon-sub { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon-sub { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon-sub { + width: 0.875rem; + height: 0.875rem; + } + + /* dropdown menus or arrows that are slightly smaller */ + .tiptap-button-dropdown-arrows { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="large"] .tiptap-button-dropdown-arrows { + width: 0.875rem; + height: 0.875rem; + } + + &[data-size="small"] .tiptap-button-dropdown-arrows { + width: 0.625rem; + height: 0.625rem; + } + + /* dropdown menu for icon buttons only */ + .tiptap-button-dropdown-small { + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-button-dropdown-small { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="small"] .tiptap-button-dropdown-small { + width: 0.5rem; + height: 0.5rem; + } + + /* button only has icons */ + &:has(> svg):not(:has(> :not(svg))) { + gap: 0.125rem; + + &[data-size="large"], + &[data-size="small"] { + gap: 0.125rem; + } + } + + /* button only has 2 icons and one of them is dropdown small */ + &:has(> svg:nth-of-type(2)):has(> .tiptap-button-dropdown-small):not( + :has(> svg:nth-of-type(3)) + ):not(:has(> .tiptap-button-text)) { + gap: 0; + padding-right: 0.25rem; + + &[data-size="large"] { + padding-right: 0.375rem; + } + + &[data-size="small"] { + padding-right: 0.25rem; + } + } + + /* Emoji is used in a button */ + .tiptap-button-emoji { + width: 1rem; + display: flex; + justify-content: center; + } + + &[data-size="large"] .tiptap-button-emoji { + width: 1.125rem; + } + + &[data-size="small"] .tiptap-button-emoji { + width: 0.875rem; + } +} + +/* -------------------------------------------- +----------- BUTTON COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-button { + background-color: var(--tt-button-default-bg-color); + color: var(--tt-button-default-text-color); + + .tiptap-button-icon { + color: var(--tt-button-default-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-default-icon-sub-color); + } + + .tiptap-button-dropdown-arrows { + color: var(--tt-button-default-dropdown-arrows-color); + } + + .tiptap-button-dropdown-small { + color: var(--tt-button-default-dropdown-arrows-color); + } + + /* hover state of a button */ + &:hover:not([data-active-item="true"]):not([disabled]), + &[data-active-item="true"]:not([disabled]), + &[data-highlighted]:not([disabled]):not([data-highlighted="false"]) { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + + .tiptap-button-icon { + color: var(--tt-button-hover-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-hover-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-hover-dropdown-arrows-color); + } + } + + /* Active state of a button */ + &[data-active-state="on"]:not([disabled]), + &[data-state="open"]:not([disabled]) { + background-color: var(--tt-button-active-bg-color); + color: var(--tt-button-active-text-color); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-button-active-bg-color-emphasized); + color: var(--tt-button-active-text-color-emphasized); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-emphasized); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-emphasized); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-emphasized); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-button-active-bg-color-subdued); + color: var(--tt-button-active-text-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-subdued); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-subdued); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + } + } + } + + &:disabled { + background-color: var(--tt-button-disabled-bg-color); + color: var(--tt-button-disabled-text-color); + + .tiptap-button-icon { + color: var(--tt-button-disabled-icon-color); + } + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/button/button.tsx b/packages/editor/src/components/tiptap-ui-primitive/button/button.tsx new file mode 100644 index 00000000..7fb8021a --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/button/button.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { forwardRef, Fragment, useMemo } from 'react'; + +// --- Tiptap UI Primitive --- +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@workspace/editor/components/tiptap-ui-primitive/tooltip'; +// --- Lib --- +import { cn, parseShortcutKeys } from '@workspace/editor/lib/tiptap-utils'; + +import '@workspace/editor/components/tiptap-ui-primitive/button/button-colors.scss'; +import '@workspace/editor/components/tiptap-ui-primitive/button/button-group.scss'; +import '@workspace/editor/components/tiptap-ui-primitive/button/button.scss'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + className?: string; + showTooltip?: boolean; + tooltip?: React.ReactNode; + shortcutKeys?: string; +} + +export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({ + shortcuts +}) => { + if (shortcuts.length === 0) return null; + + return ( +
+ {shortcuts.map((key, index) => ( + + {index > 0 && +} + {key} + + ))} +
+ ); +}; + +export const Button = forwardRef( + ( + { + className, + children, + tooltip, + showTooltip = true, + shortcutKeys, + 'aria-label': ariaLabel, + ...props + }, + ref + ) => { + const shortcuts = useMemo( + () => parseShortcutKeys({ shortcutKeys }), + [shortcutKeys] + ); + + if (!tooltip || !showTooltip) { + return ( + + ); + } + + return ( + + + {children} + + + {tooltip} + + + + ); + } +); + +Button.displayName = 'Button'; + +export const ButtonGroup = forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + orientation?: 'horizontal' | 'vertical'; + } +>(({ className, children, orientation = 'vertical', ...props }, ref) => { + return ( +
+ {children} +
+ ); +}); +ButtonGroup.displayName = 'ButtonGroup'; + +export default Button; diff --git a/packages/editor/src/components/tiptap-ui-primitive/button/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/button/index.tsx new file mode 100644 index 00000000..eaf5eea7 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/button/index.tsx @@ -0,0 +1 @@ +export * from './button'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/card/card.scss b/packages/editor/src/components/tiptap-ui-primitive/card/card.scss new file mode 100644 index 00000000..97b757e0 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/card/card.scss @@ -0,0 +1,77 @@ +:root { + --tiptap-card-bg-color: var(--white); + --tiptap-card-border-color: var(--tt-gray-light-a-100); + --tiptap-card-group-label-color: var(--tt-gray-light-a-800); +} + +.dark { + --tiptap-card-bg-color: var(--tt-gray-dark-50); + --tiptap-card-border-color: var(--tt-gray-dark-a-100); + --tiptap-card-group-label-color: var(--tt-gray-dark-a-800); +} + +.tiptap-card { + --padding: 0.375rem; + --border-width: 1px; + + border-radius: calc(var(--padding) + var(--tt-radius-lg)); + box-shadow: var(--tt-shadow-elevated-md); + background-color: var(--tiptap-card-bg-color); + border: 1px solid var(--tiptap-card-border-color); + display: flex; + flex-direction: column; + outline: none; + align-items: center; + + position: relative; + min-width: 0; + word-wrap: break-word; + background-clip: border-box; +} + +.tiptap-card-header { + padding: 0.375rem; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border-bottom: var(--border-width) solid var(--tiptap-card-border-color); +} + +.tiptap-card-body { + padding: 0.375rem; + flex: 1 1 auto; + overflow-y: auto; +} + +.tiptap-card-item-group { + position: relative; + display: flex; + vertical-align: middle; + min-width: max-content; + + &[data-orientation="vertical"] { + flex-direction: column; + justify-content: center; + } + + &[data-orientation="horizontal"] { + gap: 0.25rem; + flex-direction: row; + align-items: center; + } +} + +.tiptap-card-group-label { + padding-top: 0.75rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-bottom: 0.25rem; + line-height: normal; + font-size: 0.75rem; + font-weight: 600; + line-height: normal; + text-transform: capitalize; + color: var(--tiptap-card-group-label-color); +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/card/card.tsx b/packages/editor/src/components/tiptap-ui-primitive/card/card.tsx new file mode 100644 index 00000000..8ae00412 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/card/card.tsx @@ -0,0 +1,96 @@ +import { forwardRef } from 'react'; + +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +import '@workspace/editor/components/tiptap-ui-primitive/card/card.scss'; + +const Card = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +Card.displayName = 'Card'; + +const CardHeader = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +CardHeader.displayName = 'CardHeader'; + +const CardBody = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +CardBody.displayName = 'CardBody'; + +const CardItemGroup = forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + orientation?: 'horizontal' | 'vertical'; + } +>(({ className, orientation = 'vertical', ...props }, ref) => { + return ( +
+ ); +}); +CardItemGroup.displayName = 'CardItemGroup'; + +const CardGroupLabel = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +CardGroupLabel.displayName = 'CardGroupLabel'; + +const CardFooter = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ); + } +); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardBody, + CardItemGroup, + CardGroupLabel +}; diff --git a/packages/editor/src/components/tiptap-ui-primitive/card/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/card/index.tsx new file mode 100644 index 00000000..cb5809fe --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/card/index.tsx @@ -0,0 +1 @@ +export * from './card'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/combobox/combobox.scss b/packages/editor/src/components/tiptap-ui-primitive/combobox/combobox.scss new file mode 100644 index 00000000..7b122bbb --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/combobox/combobox.scss @@ -0,0 +1,34 @@ +.tiptap-combobox-list { + --tt-combobox-bg-color: var(--white); + --tt-combobox-border-color: var(--tt-gray-light-a-100); + --tt-combobox-text-color: var(--tt-gray-light-a-600); + + .dark & { + --tt-combobox-border-color: var(--tt-gray-dark-a-50); + --tt-combobox-bg-color: var(--tt-gray-dark-50); + --tt-combobox-text-color: var(--tt-gray-dark-a-600); + } + + --padding: 0.375rem; + --border-width: 1px; + + height: 100%; + border-radius: calc( + var(--padding) + var(--tt-radius-lg) + var(--border-width) + ); + border: var(--border-width) solid var(--tt-combobox-border-color); + background-color: var(--tt-combobox-bg-color); + color: var(--tt-combobox-text-color); + padding: var(--padding); + box-shadow: var(--tt-shadow-elevated-md); + outline: none; + + max-width: 16rem; + max-height: var(--popover-available-height); + overflow-y: auto; + margin-block: 0.375rem; + + &:empty { + display: none !important; + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/combobox/combobox.tsx b/packages/editor/src/components/tiptap-ui-primitive/combobox/combobox.tsx new file mode 100644 index 00000000..3dcf4f40 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/combobox/combobox.tsx @@ -0,0 +1,73 @@ +import { forwardRef } from 'react'; +import * as Ariakit from '@ariakit/react'; + +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +import '@workspace/editor/components/tiptap-ui-primitive/combobox/combobox.scss'; + +export function ComboboxProvider({ ...props }: Ariakit.ComboboxProviderProps) { + return ( + + ); +} + +export const ComboboxList = forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); +ComboboxList.displayName = 'ComboboxList'; + +export const ComboboxPopover = forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); +ComboboxPopover.displayName = 'ComboboxPopover'; + +export const Combobox = forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); +Combobox.displayName = 'Combobox'; + +export const ComboboxItem = forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); +ComboboxItem.displayName = 'ComboboxItem'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/combobox/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/combobox/index.tsx new file mode 100644 index 00000000..36dd8c5b --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/combobox/index.tsx @@ -0,0 +1 @@ +export * from './combobox'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss b/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss new file mode 100644 index 00000000..03b47e86 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss @@ -0,0 +1,63 @@ +.tiptap-dropdown-menu { + --tt-dropdown-menu-bg-color: var(--white); + --tt-dropdown-menu-border-color: var(--tt-gray-light-a-100); + --tt-dropdown-menu-text-color: var(--tt-gray-light-a-600); + + .dark & { + --tt-dropdown-menu-border-color: var(--tt-gray-dark-a-50); + --tt-dropdown-menu-bg-color: var(--tt-gray-dark-50); + --tt-dropdown-menu-text-color: var(--tt-gray-dark-a-600); + } +} + +/* -------------------------------------------- + --------- DROPDOWN MENU STYLING SETTINGS ----------- + -------------------------------------------- */ +.tiptap-dropdown-menu { + z-index: 50; + outline: none; + transform-origin: var(--radix-dropdown-menu-content-transform-origin); + max-height: var(--radix-dropdown-menu-content-available-height); + + > * { + max-height: var(--radix-dropdown-menu-content-available-height); + } + + /* Animation states */ + &[data-state="open"] { + animation: + fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: + fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + /* Position-based animations */ + &[data-side="top"], + &[data-side="top-start"], + &[data-side="top-end"] { + animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="right"], + &[data-side="right-start"], + &[data-side="right-end"] { + animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="bottom"], + &[data-side="bottom-start"], + &[data-side="bottom-end"] { + animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="left"], + &[data-side="left-start"], + &[data-side="left-end"] { + animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1); + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx b/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 00000000..1fc8278f --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { forwardRef } from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; + +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +import '@workspace/editor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss'; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +const DropdownMenuTrigger = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => ( + +)); +DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuItem = DropdownMenuPrimitive.Item; + +const DropdownMenuSubTrigger = DropdownMenuPrimitive.SubTrigger; + +const DropdownMenuSubContent = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + portal?: boolean | React.ComponentProps; + } +>(({ className, portal = true, ...props }, ref) => { + const content = ( + + ); + + return portal ? ( + + {content} + + ) : ( + content + ); +}); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + portal?: boolean; + } +>(({ className, sideOffset = 4, portal = false, ...props }, ref) => { + const content = ( + e.preventDefault()} + className={cn('tiptap-dropdown-menu', className)} + {...props} + /> + ); + + return portal ? ( + + {content} + + ) : ( + content + ); +}); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuSub, + DropdownMenuPortal, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup +}; diff --git a/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/index.tsx new file mode 100644 index 00000000..2759d3ce --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from './dropdown-menu'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/input/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/input/index.tsx new file mode 100644 index 00000000..e3365cb9 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/input/index.tsx @@ -0,0 +1 @@ +export * from './input'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/input/input.scss b/packages/editor/src/components/tiptap-ui-primitive/input/input.scss new file mode 100644 index 00000000..b9f777cf --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/input/input.scss @@ -0,0 +1,45 @@ +:root { + --tiptap-input-placeholder: var(--tt-gray-light-a-400); +} + +.dark { + --tiptap-input-placeholder: var(--tt-gray-dark-a-400); +} + +.tiptap-input { + display: block; + width: 100%; + height: 2rem; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + padding: 0.375rem 0.5rem; + border-radius: 0.375rem; + background: none; + appearance: none; + outline: none; + + &::placeholder { + color: var(--tiptap-input-placeholder); + } +} + +.tiptap-input-clamp { + min-width: 12rem; + padding-right: 0; + + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + text-overflow: clip; + overflow: visible; + } +} + +.tiptap-input-group { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: stretch; +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/input/input.tsx b/packages/editor/src/components/tiptap-ui-primitive/input/input.tsx new file mode 100644 index 00000000..6db1501c --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/input/input.tsx @@ -0,0 +1,30 @@ +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +import '@workspace/editor/components/tiptap-ui-primitive/input/input.scss'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +function InputGroup({ + className, + children, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ {children} +
+ ); +} + +export { Input, InputGroup }; diff --git a/packages/editor/src/components/tiptap-ui-primitive/label/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/label/index.tsx new file mode 100644 index 00000000..301fbded --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/label/index.tsx @@ -0,0 +1 @@ +export * from './label'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/label/label.scss b/packages/editor/src/components/tiptap-ui-primitive/label/label.scss new file mode 100644 index 00000000..ec7be371 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/label/label.scss @@ -0,0 +1,19 @@ +.tiptap-label { + --tt-label-color: var(--tt-gray-light-a-800); + + .dark & { + --tt-label-color: var(--tt-gray-dark-a-800); + } +} + +.tiptap-label { + margin-top: 0.75rem; + margin-left: 0.5rem; + margin-right: 0.5rem; + margin-bottom: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + line-height: normal; + text-transform: capitalize; + color: var(--tt-label-color); +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/label/label.tsx b/packages/editor/src/components/tiptap-ui-primitive/label/label.tsx new file mode 100644 index 00000000..24eb59cb --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/label/label.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { createElement, forwardRef } from 'react'; + +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +import '@workspace/editor/components/tiptap-ui-primitive/label/label.scss'; + +export interface BaseProps extends React.HTMLAttributes { + as?: 'label' | 'div'; + onMouseDown?: React.MouseEventHandler; +} + +export type LabelProps = T extends 'label' + ? BaseProps & { htmlFor?: string } + : BaseProps; + +export const Label = forwardRef< + HTMLElement, + LabelProps<'label'> | LabelProps<'div'> +>(({ as = 'div', ...props }, ref) => { + const renderProps = { ...props }; + + if (as === 'label') { + renderProps.onMouseDown = (event: React.MouseEvent) => { + // only prevent text selection if clicking inside the label itself + const target = event.target as HTMLElement; + if (target.closest('button, input, select, textarea')) return; + props.onMouseDown?.(event); + // prevent text selection when double clicking label + if (!event.defaultPrevented && event.detail > 1) event.preventDefault(); + }; + } + + return createElement(as, { + ...renderProps, + ref, + className: cn('tiptap-label', props.className) + }); +}); + +Label.displayName = 'Label'; + +export default Label; diff --git a/packages/editor/src/components/tiptap-ui-primitive/menu/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/menu/index.tsx new file mode 100644 index 00000000..d6a50c20 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/menu/index.tsx @@ -0,0 +1,5 @@ +export * from './menu'; +export * from './menu-context'; +export * from './menu-hooks'; +export * from './menu-types'; +export * from './menu-utils'; diff --git a/packages/editor/src/components/tiptap-ui-primitive/menu/menu-context.ts b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-context.ts new file mode 100644 index 00000000..c613439c --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-context.ts @@ -0,0 +1,20 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { MenuContextValue } from '@workspace/editor/components/tiptap-ui-primitive/menu/menu-types'; + +export const SearchableContext = createContext(false); + +export const MenuContext = createContext({ + isRootMenu: false, + open: false +}); + +export const useSearchableContext = (): boolean => { + return useContext(SearchableContext); +}; + +export const useMenuContext = (): MenuContextValue => { + return useContext(MenuContext); +}; diff --git a/packages/editor/src/components/tiptap-ui-primitive/menu/menu-hooks.ts b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-hooks.ts new file mode 100644 index 00000000..e4f3b490 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-hooks.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import * as Ariakit from '@ariakit/react'; + +import type { + ContextMenuAnchor, + UseContextMenuReturn, + UseMenuStoreReturn +} from '@workspace/editor/components/tiptap-ui-primitive/menu/menu-types'; + +export function useComboboxValueState(): readonly [ + string, + (value: string) => void +] { + const store = Ariakit.useComboboxContext(); + const searchValue = Ariakit.useStoreState(store, 'value') ?? ''; + + if (!store) { + throw new Error( + 'useComboboxValueState must be used within ComboboxProvider' + ); + } + + return [searchValue, store.setValue] as const; +} + +export function useMenuPlacement(): string { + const store = Ariakit.useMenuStore(); + const currentPlacement = Ariakit.useStoreState( + store, + (state) => state.currentPlacement?.split('-')[0] || 'bottom' + ); + return currentPlacement; +} + +export function useContextMenu( + anchorRect: ContextMenuAnchor +): UseContextMenuReturn { + const menu = Ariakit.useMenuStore(); + + useEffect(() => { + if (anchorRect) { + menu.render(); + } + }, [anchorRect, menu]); + + const getAnchorRect = useCallback(() => anchorRect, [anchorRect]); + + const show = useCallback(() => { + menu.show(); + menu.setAutoFocusOnShow(true); + }, [menu]); + + return useMemo( + () => ({ + store: menu, + getAnchorRect, + show + }), + [menu, getAnchorRect, show] + ); +} + +export function useFloatingMenuStore(): UseMenuStoreReturn { + const menu = Ariakit.useMenuStore(); + + const show = useCallback( + (anchorElement: HTMLElement) => { + menu.setAnchorElement(anchorElement); + menu.show(); + menu.setAutoFocusOnShow(true); + }, + [menu] + ); + + return useMemo( + () => ({ + store: menu, + show + }), + [menu, show] + ); +} + +export function useMenuItemClick( + menu?: Ariakit.MenuStore, + preventClose?: boolean +) { + return useCallback( + (event: React.MouseEvent) => { + const expandable = event.currentTarget.hasAttribute('aria-expanded'); + + if (expandable || preventClose) { + return false; + } + + menu?.hideAll(); + return false; + }, + [menu, preventClose] + ); +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/menu/menu-types.ts b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-types.ts new file mode 100644 index 00000000..3a29a603 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-types.ts @@ -0,0 +1,59 @@ +'use client'; + +import type * as React from 'react'; +import type * as Ariakit from '@ariakit/react'; + +export interface Action { + filterItems?: boolean; + group?: string; + icon?: React.ReactNode; + items?: Action[]; + keywords?: string[]; + label?: string; + value?: string; +} + +export interface MenuItemProps extends Omit< + Ariakit.ComboboxItemProps, + 'store' +> { + group?: string; + name?: string; + parentGroup?: string; + preventClose?: boolean; +} + +export interface MenuContextValue { + isRootMenu: boolean; + open: boolean; +} + +export interface MenuProps extends Ariakit.MenuProviderProps { + trigger?: React.ReactNode; + value?: string; + onOpenChange?: Ariakit.MenuProviderProps['setOpen']; + onValueChange?: Ariakit.ComboboxProviderProps['setValue']; + onValuesChange?: Ariakit.MenuProviderProps['setValues']; +} + +export interface MenuContentProps extends React.ComponentProps< + typeof Ariakit.Menu +> { + onClickOutside?: (event: MouseEvent | TouchEvent | FocusEvent) => void; +} + +export interface ContextMenuAnchor { + x: number; + y: number; +} + +export interface UseContextMenuReturn { + store: ReturnType; + getAnchorRect: () => ContextMenuAnchor; + show: () => void; +} + +export interface UseMenuStoreReturn { + store: ReturnType; + show: (anchorElement: HTMLElement) => void; +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/menu/menu-utils.ts b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-utils.ts new file mode 100644 index 00000000..1476be9d --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/menu/menu-utils.ts @@ -0,0 +1,62 @@ +import type { Action } from '@workspace/editor/components/tiptap-ui-primitive/menu/menu-types'; + +/** + * Filters menu items based on search value + * @param group - The action group containing items to filter + * @param searchValue - The search string to filter against + * @returns Filtered array of actions + */ +export function filterMenuItems( + { items = [], ...group }: Action, + searchValue: string +): Action[] { + if (!searchValue.trim()) return items; + + const normalizedSearchValue = searchValue.toLowerCase().trim(); + + const groupKeywords = [group.label, ...(group.keywords || [])] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + if (groupKeywords.includes(normalizedSearchValue)) { + return items; + } + + return items.filter((item) => { + if (item.filterItems) return true; + + const itemKeywords = [item.label, item.value, ...(item.keywords || [])] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return itemKeywords.includes(normalizedSearchValue); + }); +} + +/** + * Filters menu groups based on search value + * @param menuGroups - Array of action groups to filter + * @param searchValue - The search string to filter against + * @returns Filtered array of action groups + */ +export function filterMenuGroups( + menuGroups: Action[], + searchValue: string +): Action[] { + if (!searchValue.trim()) return menuGroups; + + return menuGroups.reduce((acc, group) => { + const filteredItems = filterMenuItems(group, searchValue); + + if (filteredItems.length > 0) { + acc.push({ + ...group, + items: filteredItems + }); + } + + return acc; + }, []); +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/menu/menu.scss b/packages/editor/src/components/tiptap-ui-primitive/menu/menu.scss new file mode 100644 index 00000000..0ad3bc10 --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/menu/menu.scss @@ -0,0 +1,51 @@ +.tiptap-menu-content { + z-index: 50; + display: flex; + flex-direction: column; + height: 100%; + outline: none; + min-width: var(--popover-anchor-width); + + &[data-state="closed"] { + display: none; + } + + &[data-state="open"] { + animation: popover 150ms ease-out; + } +} + +.tiptap-menu-group { + display: none; + + &:has([role="menuitem"]), + &:has([role="option"]) { + display: block; + } +} + +.tiptap-menu-item { + width: 100%; +} + +@keyframes popover { + from { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes zoom { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/packages/editor/src/components/tiptap-ui-primitive/menu/menu.tsx b/packages/editor/src/components/tiptap-ui-primitive/menu/menu.tsx new file mode 100644 index 00000000..d14a173e --- /dev/null +++ b/packages/editor/src/components/tiptap-ui-primitive/menu/menu.tsx @@ -0,0 +1,241 @@ +import { forwardRef, useCallback, useMemo, useRef, useState } from 'react'; +import * as Ariakit from '@ariakit/react'; + +// -- UI Primitives -- +import { + ComboboxItem, + ComboboxProvider +} from '@workspace/editor/components/tiptap-ui-primitive/combobox'; +import { Label } from '@workspace/editor/components/tiptap-ui-primitive/label'; +// -- Local imports -- +import type { + MenuContentProps, + MenuItemProps, + MenuProps +} from '@workspace/editor/components/tiptap-ui-primitive/menu'; +import { + MenuContext, + SearchableContext, + useMenuContext, + useMenuItemClick, + useMenuPlacement, + useSearchableContext +} from '@workspace/editor/components/tiptap-ui-primitive/menu'; +import { useComposedRef } from '@workspace/editor/hooks/use-composed-ref'; +// -- Hooks -- +import { useOnClickOutside } from '@workspace/editor/hooks/use-on-click-outside'; +// -- Utils -- +import { cn } from '@workspace/editor/lib/tiptap-utils'; + +// -- Styles -- +import '@workspace/editor/components/tiptap-ui-primitive/menu/menu.scss'; + +export function Menu({ + children, + trigger, + value, + onOpenChange, + onValueChange, + onValuesChange, + ...props +}: MenuProps) { + const isRootMenu = !Ariakit.useMenuContext(); + const [open, setOpen] = useState(false); + const searchable = !!onValuesChange || isRootMenu; + + const handleOpenChange = useCallback( + (v: boolean) => { + if (props.open === undefined) { + setOpen(v); + } + onOpenChange?.(v); + }, + [props.open, onOpenChange] + ); + + const menuContextValue = useMemo( + () => ({ + isRootMenu, + open: props.open ?? open + }), + [isRootMenu, props.open, open] + ); + + const menuProvider = ( + + {trigger} + + + {children} + + + + ); + + if (searchable) { + return ( + + {menuProvider} + + ); + } + + return menuProvider; +} + +export function MenuContent({ + children, + className, + ref, + onClickOutside, + ...props +}: MenuContentProps) { + const menuRef = useRef(null); + const { open } = useMenuContext(); + const side = useMenuPlacement(); + + useOnClickOutside(menuRef, onClickOutside || (() => {})); + + return ( + + {children} + + ); +} + +export const MenuButton = forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +MenuButton.displayName = 'MenuButton'; + +export const MenuButtonArrow = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +MenuButtonArrow.displayName = 'MenuButtonArrow'; + +export const MenuGroup = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +MenuGroup.displayName = 'MenuGroup'; + +export const MenuGroupLabel = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( +