Compare commits

...

5 Commits

Author SHA1 Message Date
Nick Fitzpatrick
8747c97ae2 trying handles as decorations 2025-09-09 16:38:02 -03:00
Denis Berezin
a4c4ed1b2c Add Card node title ellipsification 2025-09-05 19:46:39 +03:00
Denis Berezin
4b75e74c3d Refactor to use grid container 2025-09-05 18:58:05 +03:00
Denis Berezin
32c7cbc728 Table layout and dnd polishing 2025-09-04 23:25:26 +03:00
Denis Berezin
d52c734799 Add working table with 2 columns 2025-09-04 18:55:45 +03:00
10 changed files with 967 additions and 45 deletions

View File

@@ -198,3 +198,49 @@
border-radius: var(--mantine-radius-xs);
}
}
.editorContent
:global(.ProseMirror)
:global(table):not(:global(.editor-style-boundary) *) {
border-collapse: collapse;
margin: 0;
overflow: hidden;
table-layout: fixed;
width: 900px;
/* Prevent table from growing beyond max-width */
box-sizing: border-box;
:global(td) {
border: none;
box-sizing: border-box;
min-width: 200px;
padding: 0 0.5rem;
position: relative;
vertical-align: top;
> * {
margin-bottom: 0;
}
}
:global(.column-resize-handle) {
background-color: purple;
bottom: -2px;
pointer-events: none;
position: absolute;
right: -2px;
top: 0;
width: 4px;
cursor: col-resize;
}
}
.editorContent :global(.ProseMirror) :global(.tableWrapper) {
overflow: hidden;
}
.editorContent
:global(.ProseMirror):global(.resize-cursor)
:global(.tableWrapper) {
cursor: col-resize;
}

View File

@@ -1,6 +1,8 @@
import { DndContext, pointerWithin } from "@dnd-kit/core";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import type { EditorState } from "@tiptap/pm/state";
import type { JSONContent, Editor as TiptapEditor } from "@tiptap/react";
import { EditorContent, useEditor } from "@tiptap/react";
@@ -25,6 +27,7 @@ import { CommandExtension } from "./extensions/Command/CommandExtension";
import { CommandSuggestion } from "./extensions/Command/CommandSuggestion";
import { CustomStarterKit } from "./extensions/CustomStarterKit/CustomStarterKit";
import { DisableMetabotSidebar } from "./extensions/DisableMetabotSidebar";
import { FlexContainer } from "./extensions/FlexContainer/FlexContainer";
import { MentionExtension } from "./extensions/Mention/MentionExtension";
import { MentionSuggestion } from "./extensions/Mention/MentionSuggestion";
import { MetabotNode, type PromptSerializer } from "./extensions/MetabotEmbed";
@@ -32,7 +35,11 @@ import { MetabotMentionExtension } from "./extensions/MetabotMention/MetabotMent
import { MetabotMentionSuggestion } from "./extensions/MetabotMention/MetabotSuggestion";
import { SmartLink } from "./extensions/SmartLink/SmartLinkNode";
import { createSuggestionRenderer } from "./extensions/suggestionRenderer";
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
import {
useCardEmbedDnD,
useCardEmbedsTracking,
useQuestionSelection,
} from "./hooks";
import type { CardEmbedRef } from "./types";
const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
@@ -40,6 +47,7 @@ const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
MetabotNode.name,
SmartLink.name,
Image.name,
FlexContainer.name,
"codeBlock",
];
@@ -117,6 +125,7 @@ export const Editor: React.FC<EditorProps> = ({
placeholder: t`Start writing, press "/" to open command palette, or "@" to insert a link...`,
}),
CardEmbed,
FlexContainer,
MentionExtension.configure({
suggestion: {
allow: ({ state }) => !isMetabotBlock(state),
@@ -139,6 +148,13 @@ export const Editor: React.FC<EditorProps> = ({
render: createSuggestionRenderer(MetabotMentionSuggestion),
},
}),
TableKit.configure({
table: {
resizable: true,
cellMinWidth: 200,
lastColumnResizable: false,
},
}),
],
[siteUrl, getState],
);
@@ -189,6 +205,7 @@ export const Editor: React.FC<EditorProps> = ({
useCardEmbedsTracking(editor, onCardEmbedsChange);
useQuestionSelection(editor, onQuestionSelect);
const { handleDragEnd } = useCardEmbedDnD(editor);
if (!editor) {
return null;
@@ -203,44 +220,46 @@ export const Editor: React.FC<EditorProps> = ({
}
return (
<Box className={cx(S.editor, DND_IGNORE_CLASS_NAME)}>
<Box
className={S.editorContent}
onClick={(e) => {
// Focus editor when clicking on empty space
const target = e.target as HTMLElement;
if (
target.classList.contains(S.editorContent) ||
target.classList.contains("ProseMirror")
) {
const clickY = e.clientY;
const proseMirrorElement = target.querySelector(".ProseMirror");
<DndContext onDragEnd={handleDragEnd} collisionDetection={pointerWithin}>
<Box className={cx(S.editor, DND_IGNORE_CLASS_NAME)}>
<Box
className={S.editorContent}
onClick={(e) => {
// Focus editor when clicking on empty space
const target = e.target as HTMLElement;
if (
target.classList.contains(S.editorContent) ||
target.classList.contains("ProseMirror")
) {
const clickY = e.clientY;
const proseMirrorElement = target.querySelector(".ProseMirror");
if (proseMirrorElement) {
const proseMirrorRect =
proseMirrorElement.getBoundingClientRect();
const isClickBelowContent = clickY > proseMirrorRect.bottom;
if (proseMirrorElement) {
const proseMirrorRect =
proseMirrorElement.getBoundingClientRect();
const isClickBelowContent = clickY > proseMirrorRect.bottom;
if (isClickBelowContent) {
// Only move to end if clicking below the actual content
editor.commands.focus("end");
if (isClickBelowContent) {
// Only move to end if clicking below the actual content
editor.commands.focus("end");
} else {
// Just focus without changing cursor position for clicks in padding areas
editor.commands.focus();
}
} else {
// Just focus without changing cursor position for clicks in padding areas
// Fallback: just focus without position change
editor.commands.focus();
}
} else {
// Fallback: just focus without position change
editor.commands.focus();
}
}
}}
>
<EditorContent data-testid="document-content" editor={editor} />
<EditorBubbleMenu
editor={editor}
disallowedNodes={BUBBLE_MENU_DISALLOWED_NODES}
/>
}}
>
<EditorContent data-testid="document-content" editor={editor} />
<EditorBubbleMenu
editor={editor}
disallowedNodes={BUBBLE_MENU_DISALLOWED_NODES}
/>
</Box>
</Box>
</Box>
</DndContext>
);
};

View File

@@ -30,7 +30,7 @@
}
.questionHeader {
padding: 0.5rem 1rem;
padding: 0.5rem 2rem;
}
.questionResults {
@@ -51,11 +51,19 @@
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
flex-shrink: 1;
}
.titleText {
text-overflow: ellipsis;
overflow: hidden;
}
.titleEditIcon {
opacity: 0;
transition: opacity 0.2s ease;
flex-shrink: 0;
}
.titleContainer:hover .titleEditIcon {
@@ -66,3 +74,29 @@
border: none;
border-radius: var(--mantine-radius-xs);
}
.dragHandle {
cursor: grab;
padding: 0.25rem;
border-radius: var(--mantine-radius-xs);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
position: absolute;
top: 0.5rem;
left: 0.5rem;
&:hover {
background-color: var(--mb-color-bg-light);
}
&:active {
cursor: grabbing;
}
}
.cardEmbed:hover .dragHandle {
opacity: 1;
}

View File

@@ -1,3 +1,7 @@
/* eslint-disable no-color-literals */
// TODO - remove this
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { Node, mergeAttributes } from "@tiptap/core";
import {
type NodeViewProps,
@@ -8,6 +12,7 @@ import cx from "classnames";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { t } from "ttag";
import { Ellipsified } from "metabase/common/components/Ellipsified";
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
import { useDispatch, useSelector } from "metabase/lib/redux";
@@ -35,6 +40,39 @@ import styles from "./CardEmbedNode.module.css";
import { ModifyQuestionModal } from "./ModifyQuestionModal";
import { NativeQueryModal } from "./NativeQueryModal";
interface DropZoneProps {
id: string;
side: "left" | "right";
disabled?: boolean;
}
const DropZone = ({ id, side, disabled }: DropZoneProps) => {
// TODO: disable drop area if card is dragged over itself
const { isOver, setNodeRef } = useDroppable({
id: `drop-zone-${id}-${side}`,
disabled,
});
return (
<Box
ref={setNodeRef}
style={{
position: "absolute",
top: 0,
bottom: 0,
width: "0.25rem",
[side]: "-0.75rem",
borderRadius: "0.125rem",
backgroundColor: isOver
? "var(--mb-base-color-blue-30)"
: "transparent",
zIndex: 10,
pointerEvents: "all",
}}
/>
);
};
function formatCardEmbed(attrs: CardEmbedAttributes): string {
if (attrs.name) {
return `{% card id=${attrs.id} name="${attrs.name}" %}`;
@@ -63,7 +101,6 @@ export const CardEmbed: Node<{
name: "cardEmbed",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
@@ -124,6 +161,28 @@ export const CardEmbedComponent = memo(
const dispatch = useDispatch();
const canWrite = editor.options.editable;
// Set up draggable functionality
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
transform,
isDragging,
} = useDraggable({
id: `card-embed-${id}`,
disabled: !canWrite,
});
const draggableStyle = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : "auto",
}
: {};
const dragHandleRef = useRef<HTMLDivElement>(null);
let embedIndex = -1;
if (editor && getPos) {
@@ -274,14 +333,35 @@ export const CardEmbedComponent = memo(
if (isLoading && !card) {
return (
<NodeViewWrapper className={styles.embedWrapper}>
<NodeViewWrapper
className={styles.embedWrapper}
style={{ position: "relative" }}
>
<Box
ref={setDraggableRef}
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
style={{
...draggableStyle,
}}
>
<Box className={styles.questionHeader}>
<Flex align="center" justify="space-between" gap="0.5rem">
{canWrite && (
<Box
ref={dragHandleRef}
className={styles.dragHandle}
{...attributes}
{...listeners}
>
<Icon
name="grabber"
size={16}
color="var(--mb-color-text-medium)"
/>
</Box>
)}
<Box className={styles.titleContainer}>
<Text size="md" color="text-dark" fw={700}>
{t`Loading question...`}
@@ -304,12 +384,40 @@ export const CardEmbedComponent = memo(
<NodeViewWrapper
className={styles.embedWrapper}
data-testid="document-card-embed"
style={{ position: "relative" }}
>
<Box
ref={setDraggableRef}
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
style={{
...draggableStyle,
}}
>
<Box className={styles.questionHeader}>
<Flex align="center" justify="space-between" gap="0.5rem">
{canWrite && (
<Box
ref={dragHandleRef}
className={styles.dragHandle}
{...attributes}
{...listeners}
>
<Icon
name="grabber"
size={16}
color="var(--mb-color-text-medium)"
/>
</Box>
)}
<Box className={styles.titleContainer}>
<Text size="md" color="text-dark" fw={700}>
{t`Error loading question`}
</Text>
</Box>
</Flex>
</Box>
<Flex className={styles.questionResults}>
<ErrorView
error={
@@ -328,15 +436,40 @@ export const CardEmbedComponent = memo(
<NodeViewWrapper
className={styles.embedWrapper}
data-testid="document-card-embed"
style={{ position: "relative" }}
>
{canWrite && id && (
<>
<DropZone id={id.toString()} side="left" disabled={isDragging} />
<DropZone id={id.toString()} side="right" disabled={isDragging} />
</>
)}
<Box
ref={setDraggableRef}
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
style={{
...draggableStyle,
}}
>
{card && (
<Box className={styles.questionHeader}>
<Flex align="center" justify="space-between" gap="0.5rem">
{canWrite && (
<Box
ref={dragHandleRef}
className={styles.dragHandle}
{...attributes}
{...listeners}
>
<Icon
name="grabber"
size={16}
color="var(--mb-color-text-medium)"
/>
</Box>
)}
{isEditingTitle ? (
<TextInput
ref={titleInputRef}
@@ -366,15 +499,20 @@ export const CardEmbedComponent = memo(
/>
) : (
<Box className={styles.titleContainer}>
<Text
size="md"
color="text-dark"
fw={700}
onClick={handleTitleClick}
c="pointer"
>
{displayName}
</Text>
<Ellipsified lines={1} tooltip={displayName}>
<Text
className={styles.titleText}
size="md"
color="text-dark"
fw={700}
c="pointer"
truncate="end"
onClick={handleTitleClick}
>
{displayName}
</Text>
</Ellipsified>
{canWrite && (
<Icon
name="pencil"

View File

@@ -0,0 +1,78 @@
.flexContainer {
display: block;
width: 100%;
margin: 1rem 0;
border-radius: var(--mantine-radius-md);
position: relative;
max-width: 900px;
}
.flexContainer.selected {
outline: 2px solid var(--mb-color-brand);
outline-offset: 2px;
}
.flexContent > :global(div[data-node-view-wrapper]) {
display: grid;
width: 100%;
gap: 1rem;
grid-template-columns: var(--mb-card-container-col-widths);
justify-content: center;
align-items: center;
}
.flexContent {
position: relative;
width: 100%;
box-sizing: border-box;
/* Grid layout will be applied via inline styles */
}
/* Ensure CardEmbed nodes within flex container take appropriate space */
.flexContent > :global(.react-renderer) {
min-width: 0; /* Allow items to shrink below their natural width */
}
/* Resize handles */
.resizeHandle {
/* position: absolute;
top: calc(50% - 2rem); */
width: 0.25rem;
height: 4rem;
margin-left: -0.125rem;
cursor: col-resize;
background-color: transparent;
border-radius: 100px;
opacity: 0;
transition:
opacity 0.2s ease,
background-color 0.2s ease;
z-index: 10;
}
.flexContainer:hover .resizeHandle {
opacity: 1;
background-color: var(--mb-color-text-secondary-inverse);
}
.flexContainer.resizing {
user-select: none;
}
.flexContainer.resizing .resizeHandle {
opacity: 1;
}
/* Visual feedback for empty container */
.flexContainer:empty::after {
content: "Drop CardEmbed items here";
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--mb-color-text-light);
font-style: italic;
border: 2px dashed var(--mb-color-border);
border-radius: var(--mantine-radius-md);
background-color: var(--mb-color-bg-light);
}

View File

@@ -0,0 +1,311 @@
import { useDndContext } from "@dnd-kit/core";
import { Node, mergeAttributes } from "@tiptap/core";
import {
NodeViewContent,
type NodeViewProps,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import type React from "react";
import { useMemo, useRef, useState } from "react";
import { Box } from "metabase/ui";
import styles from "./FlexContainer.module.css";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
const COLUMN_MIN_WIDTH = 200;
export interface FlexContainerAttributes {
class?: string;
columnWidths?: number[]; // Array of width percentages for each column
}
let editorViewRef: EditorView | null = null; // This will hold our editor view
export const FlexContainer: Node<{
HTMLAttributes: FlexContainerAttributes;
}> = Node.create({
name: "flexContainer",
group: "block",
content: "cardEmbed{1,3}", // Contains 1-3 CardEmbed nodes only
defining: true,
draggable: false,
selectable: false,
addAttributes() {
return {
columnWidths: {
default: null,
parseHTML: (element) => {
const widths = element.getAttribute("data-column-widths");
return widths ? JSON.parse(widths) : null;
},
renderHTML: (attributes) => {
return attributes.columnWidths
? {
"data-column-widths": JSON.stringify(attributes.columnWidths),
}
: {};
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${FlexContainer.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
HTMLAttributes,
{
"data-type": FlexContainer.name,
class: "flex-container",
},
this.options.HTMLAttributes,
),
0,
];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("silly-drag-handles"),
view(editorView) {
editorViewRef = editorView;
return {
destroy() {
// Clean up if needed.
editorViewRef = null;
},
};
},
props: {
decorations: (state) => {
const handleMouseDown = (
handleIndex: number,
e: MouseEvent,
parent: PMNode,
) => {
e.preventDefault();
if (editorViewRef === null) {
return;
}
let pos = 0;
state.doc.descendants((node, _pos) => {
if (node === parent) {
pos = _pos;
}
});
const container = e.target?.closest("[data-type=flexContainer]");
if (!container) {
return;
}
const currentWidths = parent.attrs.columnWidths;
const columnWidths =
(currentWidths || []).length === parent.childCount
? currentWidths
: (Array(parent.childCount).fill(
100 / parent.childCount,
) as number[]);
const containerRect = container.getBoundingClientRect();
const startX = e.clientX;
const startWidths = [...columnWidths];
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startX;
const containerWidth = containerRect.width;
const deltaPercent = (deltaX / containerWidth) * 100;
const newWidths = [...startWidths];
// Adjust the current column and next column
const leftColumnIndex = handleIndex;
const rightColumnIndex = handleIndex + 1;
// Calculate minimum width percentage based on 200px minimum
const minWidthPercent =
(COLUMN_MIN_WIDTH / containerWidth) * 100;
// Calculate new widths ensuring minimum column width
let leftNewWidth = Math.max(
minWidthPercent,
startWidths[leftColumnIndex] + deltaPercent,
);
let rightNewWidth = Math.max(
minWidthPercent,
startWidths[rightColumnIndex] - deltaPercent,
);
// Ensure the two adjusted columns don't exceed their total original width
const originalTotal =
startWidths[leftColumnIndex] + startWidths[rightColumnIndex];
const newTotal = leftNewWidth + rightNewWidth;
if (newTotal !== originalTotal) {
// Adjust proportionally to maintain the original total
const ratio = originalTotal / newTotal;
leftNewWidth *= ratio;
rightNewWidth *= ratio;
// Re-check minimum constraints after adjustment
if (
leftNewWidth < minWidthPercent ||
rightNewWidth < minWidthPercent
) {
return; // Don't update if constraints would be violated
}
}
// Only apply if both columns meet minimum requirements
if (
leftNewWidth >= minWidthPercent &&
rightNewWidth >= minWidthPercent
) {
newWidths[leftColumnIndex] = leftNewWidth;
newWidths[rightColumnIndex] = rightNewWidth;
// Verify total width is still 100%
const totalWidth = newWidths.reduce(
(sum, width) => sum + width,
0,
);
const { state, dispatch } = editorViewRef;
if (Math.abs(totalWidth - 100) > 0.01) {
// Normalize to ensure exact 100%
const normalizedWidths = newWidths.map(
(width) => (width / totalWidth) * 100,
);
const transaction = state.tr.setNodeMarkup(pos, undefined, {
columnWidths: normalizedWidths,
});
dispatch(transaction);
// updateAttributes({ columnWidths: normalizedWidths });
} else {
const transaction = state.tr.setNodeMarkup(pos, undefined, {
columnWidths: newWidths,
});
dispatch(transaction);
// updateAttributes({ columnWidths: newWidths });
}
}
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
const decorations: Decoration[] = [];
const flexContainers: { node: PMNode; pos: number }[] = [];
state.doc.content.descendants((node, pos) => {
// console.log(node);
if (node.type.name === "flexContainer") {
flexContainers.push({ node, pos });
return false;
}
});
flexContainers.forEach(({ node, pos }) => {
node.forEach((n, offset, index) => {
if (index !== 0) {
const widget = document.createElement("div");
widget.classList.add(styles.resizeHandle);
widget.onmousedown = (e: MouseEvent) => {
handleMouseDown(index - 1, e, node);
};
const deco = Decoration.widget(pos + offset + 1, widget, {
side: -1,
});
decorations.push(deco);
}
});
});
return DecorationSet.create(state.doc, decorations);
},
},
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(FlexContainerComponent);
},
});
const FlexContainerComponent: React.FC<NodeViewProps> = ({
node,
selected,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
// Get column count and current widths
const columnCount = node.content.childCount;
const currentWidths = node.attrs.columnWidths;
// Calculate default equal widths if not set
const columnWidths = useMemo(() => {
if ((currentWidths || []).length === columnCount) {
return currentWidths;
}
// Default to equal widths
return Array(columnCount).fill(100 / columnCount) as number[];
}, [currentWidths, columnCount]);
return (
<NodeViewWrapper
className={cx(styles.flexContainer, {
[styles.selected]: selected,
})}
data-type="flexContainer"
>
<Box ref={containerRef}>
<NodeViewContent
className={styles.flexContent}
style={{
"--mb-card-container-col-widths": columnWidths
.map(
(width: number) => `minmax(${COLUMN_MIN_WIDTH}px, ${width}%)`,
)
.join(" 0.25rem "),
}}
/>
{/* {renderResizeHandles(isDragging)} */}
</Box>
</NodeViewWrapper>
);
};
FlexContainerComponent.displayName = "FlexContainerComponent";

View File

@@ -1,2 +1,3 @@
export { useCardEmbedSelection as useQuestionSelection } from "./useCardEmbedSelection";
export { useCardEmbedsTracking } from "./useCardEmbedsTracking";
export { useCardEmbedDnD } from "./useCardEmbedDnD";

View File

@@ -0,0 +1,289 @@
import type { DragEndEvent } from "@dnd-kit/core";
import type { Editor as TiptapEditor } from "@tiptap/react";
import { useCallback } from "react";
export const useCardEmbedDnD = (editor: TiptapEditor | null) => {
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!active || !over || active.id === over.id) {
return;
}
// Handle reordering of CardEmbed nodes
if (editor && active.id.toString().startsWith("card-embed-")) {
const sourceId = active.id.toString().replace("card-embed-", "");
const targetZoneId = over.id.toString().replace("drop-zone-", "");
const direction = over.id.toString().includes("-left")
? "left"
: "right";
const targetId = targetZoneId
.replace("-left", "")
.replace("-right", "");
// Don't allow dropping next to itself
if (sourceId === targetId) {
return;
}
// Find the source and target nodes in the document
let sourcePos = -1;
let targetPos = -1;
let sourceNode: any = null;
let targetNode: any = null;
let targetFlexContainerPos = -1;
let targetFlexContainer: any = null;
let sourceFlexContainerPos = -1;
let sourceFlexContainer: any = null;
editor.state.doc.descendants((node, pos) => {
if (
node.type.name === "cardEmbed" &&
node.attrs.id?.toString() === sourceId
) {
sourcePos = pos;
sourceNode = node;
// Check if the source CardEmbed is inside a FlexContainer
const currentPos = pos;
editor.state.doc.nodesBetween(
0,
editor.state.doc.content.size,
(node, nodePos) => {
if (
nodePos <= currentPos &&
currentPos < nodePos + node.nodeSize
) {
if (node.type.name === "flexContainer") {
sourceFlexContainerPos = nodePos;
sourceFlexContainer = node;
}
}
},
);
}
if (
node.type.name === "cardEmbed" &&
node.attrs.id?.toString() === targetId
) {
targetPos = pos;
targetNode = node;
// Check if this CardEmbed is inside a FlexContainer by walking up the tree
const currentPos = pos;
editor.state.doc.nodesBetween(
0,
editor.state.doc.content.size,
(node, nodePos) => {
if (
nodePos <= currentPos &&
currentPos < nodePos + node.nodeSize
) {
if (node.type.name === "flexContainer") {
targetFlexContainerPos = nodePos;
targetFlexContainer = node;
}
}
},
);
}
});
if (sourcePos !== -1 && targetPos !== -1 && sourceNode && targetNode) {
const tr = editor.state.tr;
// Check if both source and target are in the same FlexContainer
const bothInSameFlexContainer =
sourceFlexContainerPos !== -1 &&
targetFlexContainerPos !== -1 &&
sourceFlexContainerPos === targetFlexContainerPos;
if (bothInSameFlexContainer) {
// Swap positions within the same FlexContainer
const flexContainer = sourceFlexContainer;
const containerContent = flexContainer.content.content
? flexContainer.content.content
: flexContainer.content;
// Find the indices for source and target CardEmbeds
let sourceIndex = -1;
let targetIndex = -1;
let childPos = sourceFlexContainerPos + 1; // Start after container opening tag
for (let i = 0; i < containerContent.length; i++) {
const child = containerContent[i];
if (
childPos <= sourcePos &&
sourcePos < childPos + child.nodeSize
) {
sourceIndex = i;
}
if (
childPos <= targetPos &&
targetPos < childPos + child.nodeSize
) {
targetIndex = i;
}
childPos += child.nodeSize;
}
if (sourceIndex !== -1 && targetIndex !== -1) {
// Create new container content with reordered CardEmbeds
const newContainerContent = [...containerContent];
if (direction === "left") {
// Insert source before target
const sourceChild = newContainerContent[sourceIndex];
newContainerContent.splice(sourceIndex, 1); // Remove source
// Adjust target index if source was before it
const adjustedTargetIndex =
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
newContainerContent.splice(adjustedTargetIndex, 0, sourceChild);
} else {
// Insert source after target
const sourceChild = newContainerContent[sourceIndex];
newContainerContent.splice(sourceIndex, 1); // Remove source
// Adjust target index if source was before it
const adjustedTargetIndex =
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
newContainerContent.splice(
adjustedTargetIndex + 1,
0,
sourceChild,
);
}
// Create the updated FlexContainer
const newFlexContainer = {
type: "flexContainer",
attrs: flexContainer.attrs,
content: newContainerContent.map((child) =>
child.toJSON ? child.toJSON() : child,
),
};
// Replace the entire FlexContainer
tr.replaceWith(
sourceFlexContainerPos,
sourceFlexContainerPos + flexContainer.nodeSize,
editor.schema.nodeFromJSON(newFlexContainer),
);
}
} else if (targetFlexContainerPos !== -1 && targetFlexContainer) {
// Target is already in a FlexContainer - add the source CardEmbed to it
const containerContent = targetFlexContainer.content.content
? targetFlexContainer.content.content
: targetFlexContainer.content;
// Check if FlexContainer already has 3 CardEmbeds (maximum allowed)
if (containerContent.length >= 3) {
return; // Don't allow more than 3 CardEmbeds
}
const newContainerContent = [...containerContent];
// Insert the source CardEmbed at the appropriate position based on direction
if (direction === "left") {
// Find the index of the target CardEmbed and insert before it
let targetIndex = 0;
let childPos = targetFlexContainerPos + 1; // Start after container opening tag
for (let i = 0; i < containerContent.length; i++) {
const child = containerContent[i];
if (
childPos <= targetPos &&
targetPos < childPos + child.nodeSize
) {
targetIndex = i;
break;
}
childPos += child.nodeSize;
}
newContainerContent.splice(targetIndex, 0, sourceNode);
} else {
// Insert after the target CardEmbed
let targetIndex = 0;
let childPos = targetFlexContainerPos + 1;
for (let i = 0; i < containerContent.length; i++) {
const child = containerContent[i];
if (
childPos <= targetPos &&
targetPos < childPos + child.nodeSize
) {
targetIndex = i + 1;
break;
}
childPos += child.nodeSize;
}
newContainerContent.splice(targetIndex, 0, sourceNode);
}
// Create the new FlexContainer with updated content
const newFlexContainer = {
type: "flexContainer",
attrs: targetFlexContainer.attrs,
content: newContainerContent.map((child) =>
child.toJSON ? child.toJSON() : child,
),
};
// Replace the FlexContainer and remove the source node
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
// Adjust container position if source was before it
const adjustedContainerPos =
sourcePos < targetFlexContainerPos
? targetFlexContainerPos - sourceNode.nodeSize
: targetFlexContainerPos;
tr.replaceWith(
adjustedContainerPos,
adjustedContainerPos + targetFlexContainer.nodeSize,
editor.schema.nodeFromJSON(newFlexContainer),
);
} else {
// Target is not in a FlexContainer - create a new FlexContainer with both CardEmbeds
const leftCardEmbed =
direction === "left" ? sourceNode.toJSON() : targetNode.toJSON();
const rightCardEmbed =
direction === "left" ? targetNode.toJSON() : sourceNode.toJSON();
const flexContainerContent = {
type: "flexContainer",
attrs: {},
content: [leftCardEmbed, rightCardEmbed],
};
// Delete nodes in reverse order to maintain positions
if (sourcePos > targetPos) {
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
tr.delete(targetPos, targetPos + targetNode.nodeSize);
} else {
tr.delete(targetPos, targetPos + targetNode.nodeSize);
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
}
// Insert the FlexContainer at the position of whichever node was first
const insertPos = Math.min(sourcePos, targetPos);
tr.insert(
insertPos,
editor.schema.nodeFromJSON(flexContainerContent),
);
}
editor.view.dispatch(tr);
}
}
},
[editor],
);
return {
handleDragEnd,
};
};

View File

@@ -40,6 +40,7 @@
"@tiptap/extension-bubble-menu": "^3.2.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-placeholder": "^3.2.0",
"@tiptap/extension-table": "^3.4.1",
"@tiptap/pm": "^3.2.0",
"@tiptap/react": "^3.2.0",
"@tiptap/starter-kit": "^3.2.0",

View File

@@ -4164,6 +4164,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.2.0.tgz#7d4998ca98d25bcbd31c3fb4ec8fb2b4fbcc371e"
integrity sha512-gIfaIJmvQNBxjvtJfNQ8LhvWuGEFVFDMJxLa80speY4ngsDYZ3K8Ea4+0FTctKJVIXEZ5cDoHZR96ELkQMPzWA==
"@tiptap/extension-table@^3.4.1":
version "3.4.1"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.4.1.tgz#44c14029a5544a97c9f998032b9b8b828e3766a9"
integrity sha512-2yXveI1632p/B/jq6Kjg5/BE85hKM5OQAkLExisxdmQ2vAEARBj6Y8iMXdsc7BJJMjwu0Q7eT3EsvDNpYJgehw==
"@tiptap/extension-text@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.2.0.tgz#8632b94150ce928c745756f017246161db529ac1"