Compare commits

...

40 Commits

Author SHA1 Message Date
Brad Anderson
badfe8c57b UXW-672 Adds supportingText node on click (handles both there is and isn't a wrapping flexContainer) 2025-09-19 16:55:17 -04:00
Brad Anderson
f8cccfc871 UXW-673 Brings back cursor and caret (removed in document-layout branch) 2025-09-19 16:55:17 -04:00
Brad Anderson
22339543d1 UXW-672 Stubs out inserting supporting text block via menu item 2025-09-19 16:55:17 -04:00
Brad Anderson
abe9015fe2 UXW-672 Removes unnecessary styles 2025-09-19 16:55:17 -04:00
Brad Anderson
1b2e0d51f9 UXW-672 Stubs out changing "Add supporting text" to menu item 2025-09-19 16:55:17 -04:00
Brad Anderson
3ef5e16451 UXW-662 Stubs out supporting text feature 2025-09-19 16:55:17 -04:00
Nick Fitzpatrick
c4af01a93f Handle dropping cards onto paragraphs (#63754)
* Handle dropping cards onto paragraphs

* PR Feedback

* cleanup flex containers

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-09-20 00:44:32 +04:00
Denis Berezin
0d1de2d3ce UXW-748 - Fix broken document when typing with a card selected 2025-09-19 23:24:54 +04:00
Denis Berezin
717fdcf142 Fix issue with moving a card from one flexContainer into another 2025-09-19 20:04:52 +04:00
Denis Berezin
edc4282fef Fix issue with dropping a card into existing flexContainer 2025-09-19 19:42:14 +04:00
Maz Ameli
009f98cbfd replace references to old icons with new icons 2025-09-18 14:24:40 -07:00
Maz Ameli
fbcbc84826 actually remove redundant message icons 2025-09-18 14:16:28 -07:00
Maz Ameli
754f87cb75 remove redundant message and add_message icons 2025-09-18 14:14:30 -07:00
Denis Berezin
fbd7245254 Refactor dnd logic - consolidate all use cases in HandleEditorDrop 2025-09-18 23:45:47 +04:00
Brad Anderson
fca680c03a UXW-749 [2/2] Fix: Copying and pasting from one chart to another doesn't preserve the structure correctly 2025-09-18 14:32:39 -04:00
Brad Anderson
e0f35fde53 UXW-749 [1/2] Fix: Cutting/copying a chart from a group then pasting it (cmd+v) results in a chart without a resizeNode 2025-09-18 14:32:09 -04:00
Brad Anderson
aba3ff0d92 UXW-739 Adds ResizeNode wrapper to charts added by metabot 2025-09-18 09:54:25 -04:00
Denis Berezin
390921bf8e Fix adding third column position 2025-09-17 23:01:28 +03:00
Denis Berezin
a26b7b6fcb Fix issues with column rearrange 2025-09-17 23:00:58 +03:00
Denis Berezin
632aeac3de Fix issues with funnel chart and resizer 2025-09-17 19:39:45 +03:00
Brad Anderson
7b8e2471fc Feat: UXW-717 Reconcile comments with document layout (#63593)
* UXW-717 Add comment option to cardEmbed menu
* UXW-717 Wires up comment option in cardEmbed menu
* UXW-717 Stubs out CommentsMenu reorganization so logic can be reused inside cardEmbeds (button and menu item)
* UXW-717 Design tweaks per design feedback (and fixes ts error)
* UXW-717 Updates hover state of cardEmbed ellipsis menu trigger per design feedback
2025-09-16 17:54:14 -04:00
Denis Berezin
020b538e08 Fix issues with cards previews on dnd 2025-09-17 00:43:35 +03:00
Denis Berezin
173a7879ac Fix issue with column resize handlers 2025-09-16 21:10:52 +03:00
Denis Berezin
d64aa8de7a Fix issues with dropzones and card id 2025-09-16 01:42:03 +03:00
Brad Anderson
f9e2301b69 Fix issue where moving a cardEmbed out of a flexContainer and placing it right after doesn't wrap the card in a resizer 2025-09-12 14:17:20 -04:00
Brad Anderson
b048a49649 Fix issue where rearranging columns within a flexContainer didn't respect which side of a cardEmbed we dropped; it always inserted dragged column to the left. 2025-09-12 12:53:45 -04:00
Denis Berezin
a74271fd08 Fix issues with dnd logic 2025-09-12 02:32:45 +03:00
Denis Berezin
403bc655d5 Fixes after merge comments 2025-09-11 22:51:50 +03:00
Denis Berezin
8f5f19c9a2 Merge branch 'master' into document-layout
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/documents/components/Editor/Editor.tsx
#	enterprise/frontend/src/metabase-enterprise/rich_text_editing/tiptap/extensions/CardEmbed/CardEmbedNode.tsx
2025-09-11 20:34:34 +03:00
Denis Berezin
df41cecf33 Document layout no dnd kit (#63363)
* using tiptap drag and drop

* Fix some issues with dnd

* Add support for resize node, fix styles

* Remove comments

---------

Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
2025-09-11 19:42:01 +03:00
Denis Berezin
6e7f6ceec9 Fix empty ResizeNode after card dnd 2025-09-08 17:22:14 +03:00
Denis Berezin
cd934a4d49 Fix FlexContainer to work with ResizeNode 2025-09-05 22:28:14 +03:00
Denis Berezin
4dfc9a0ec6 Merge branch 'master' into document-layout 2025-09-05 22:06:16 +03:00
Denis Berezin
c1fd1e8077 Merge branch 'uxw-638-ability-to-change-the-height-of-the-cards-even-if-not-side' into document-layout
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/documents/components/Editor/Editor.tsx
2025-09-05 22:06:03 +03:00
Nick Fitzpatrick
8a2eaa6c58 cleanup, add min height 2025-09-05 14:36:05 -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
Nick Fitzpatrick
b4ab64aa2b ResizeNode 2025-09-05 11:48:00 -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
29 changed files with 2266 additions and 102 deletions

View File

@@ -143,7 +143,7 @@ export const DocumentHeader = ({
aria-label={t`Show all comments`}
data-hide-on-print
>
<Icon name="message" />
<Icon name="comment" />
</ActionIcon>
)}
@@ -152,12 +152,11 @@ export const DocumentHeader = ({
className={S.commentsIcon}
component={Link}
to={`/document/${document.id}/comments/all`}
variant="subtle"
size="md"
aria-label={t`Show all comments`}
data-hide-on-print
>
<Icon name="message" />
<Icon name="comment" />
</ActionIcon>
)}
</Box>

View File

@@ -1,10 +1,15 @@
import cx from "classnames";
import { type CSSProperties, forwardRef, useMemo } from "react";
import {
type CSSProperties,
type ElementType,
forwardRef,
useMemo,
} from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { t } from "ttag";
import { Box, Button, Icon, rem } from "metabase/ui";
import { Box, Button, type ButtonProps, Icon, rem } from "metabase/ui";
import type { CommentThread } from "metabase-enterprise/comments/types";
import S from "./CommentsMenu.module.css";
@@ -18,20 +23,69 @@ interface Props {
threads: CommentThread[];
}
export const getUnresolvedComments = (
threads: CommentThread[],
): CommentThread["comments"] => {
return threads
.filter((thread) => !thread.comments[0]?.is_resolved)
.flatMap((thread) =>
thread.comments.filter((comment) => !comment.deleted_at),
);
};
interface CommentsButtonOptions {
active: boolean;
disabled: boolean;
href: string;
unresolvedCommentsCount: number;
}
export const useCommentsButton = ({
active,
disabled,
href,
unresolvedCommentsCount,
}: CommentsButtonOptions): ButtonProps & {
component?: ElementType;
to?: string;
} => {
const hasUnresolvedComments = unresolvedCommentsCount > 0;
return {
...(!disabled
? {
component: Link,
// If no existing unresolved comments comments, add query param to auto-open new comment form
to: hasUnresolvedComments ? href : `${href}?new=true`,
}
: {}),
disabled,
leftSection: (
<Icon name={hasUnresolvedComments ? "comment" : "add_comment"} />
),
px: "sm",
size: "xs",
bd: 0,
"aria-label": t`Comments`,
variant: active ? "filled" : "default",
children: hasUnresolvedComments ? unresolvedCommentsCount : null,
};
};
export const CommentsMenu = forwardRef<HTMLDivElement, Props>(
function CommentsMenu(
{ active, disabled, href, show, style, threads }: Props,
ref,
) {
const unresolvedCommentsCount = useMemo(
() =>
threads
.filter((thread) => !thread.comments[0]?.is_resolved)
.flatMap((thread) =>
thread.comments.filter((comment) => !comment.deleted_at),
).length,
() => getUnresolvedComments(threads).length,
[threads],
);
const commentsButtonProps = useCommentsButton({
active,
disabled,
href,
unresolvedCommentsCount,
});
const hasUnresolvedComments = unresolvedCommentsCount > 0;
return createPortal(
@@ -48,31 +102,7 @@ export const CommentsMenu = forwardRef<HTMLDivElement, Props>(
ref={ref}
style={style}
>
{disabled ? (
<Button
disabled
leftSection={<Icon name="message" />}
px="sm"
size="xs"
aria-label={t`Comments`}
variant={active ? "filled" : "default"}
>
{unresolvedCommentsCount > 0 ? unresolvedCommentsCount : null}
</Button>
) : (
<Button
component={Link}
// If no existing unresolved comments comments, add query param to auto-open new comment form
to={hasUnresolvedComments ? href : `${href}?new=true`}
leftSection={<Icon name="message" />}
px="sm"
size="xs"
aria-label={t`Comments`}
variant={active ? "filled" : "default"}
>
{unresolvedCommentsCount > 0 ? unresolvedCommentsCount : null}
</Button>
)}
<Button {...(commentsButtonProps as ButtonProps)} />
</Box>,
document.body,
);

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

@@ -18,11 +18,16 @@ import type { DocumentsStoreState } from "metabase-enterprise/documents/types";
import { isMetabotBlock } from "metabase-enterprise/documents/utils/editorNodeUtils";
import { getMentionsCacheKey } from "metabase-enterprise/documents/utils/mentionsUtils";
import { EditorBubbleMenu } from "metabase-enterprise/rich_text_editing/tiptap/components/EditorBubbleMenu/EditorBubbleMenu";
import { CardEmbed } from "metabase-enterprise/rich_text_editing/tiptap/extensions/CardEmbed/CardEmbedNode";
import {
CardEmbed,
DROP_ZONE_COLOR,
} from "metabase-enterprise/rich_text_editing/tiptap/extensions/CardEmbed/CardEmbedNode";
import { CommandExtension } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Command/CommandExtension";
import { CommandSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Command/CommandSuggestion";
import { CustomStarterKit } from "metabase-enterprise/rich_text_editing/tiptap/extensions/CustomStarterKit/CustomStarterKit";
import { DisableMetabotSidebar } from "metabase-enterprise/rich_text_editing/tiptap/extensions/DisableMetabotSidebar";
import { FlexContainer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/FlexContainer/FlexContainer";
import { HandleEditorDrop } from "metabase-enterprise/rich_text_editing/tiptap/extensions/HandleEditorDrop/HandleEditorDrop";
import { MentionExtension } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Mention/MentionExtension";
import { MentionSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Mention/MentionSuggestion";
import {
@@ -31,12 +36,15 @@ import {
} from "metabase-enterprise/rich_text_editing/tiptap/extensions/MetabotEmbed";
import { MetabotMentionExtension } from "metabase-enterprise/rich_text_editing/tiptap/extensions/MetabotMention/MetabotMentionExtension";
import { MetabotMentionSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/MetabotMention/MetabotSuggestion";
import { ResizeNode } from "metabase-enterprise/rich_text_editing/tiptap/extensions/ResizeNode/ResizeNode";
import { SmartLink } from "metabase-enterprise/rich_text_editing/tiptap/extensions/SmartLink/SmartLinkNode";
import { SupportingText } from "metabase-enterprise/rich_text_editing/tiptap/extensions/SupportingText/SupportingText";
import { createSuggestionRenderer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/suggestionRenderer";
import S from "./Editor.module.css";
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
import type { CardEmbedRef } from "./types";
const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
CardEmbed.name,
MetabotNode.name,
@@ -94,7 +102,12 @@ export const Editor: React.FC<EditorProps> = ({
const extensions = useMemo(
() => [
CustomStarterKit,
CustomStarterKit.configure({
dropcursor: {
color: DROP_ZONE_COLOR,
width: 2,
},
}),
Image.configure({
inline: false,
HTMLAttributes: {
@@ -116,6 +129,8 @@ export const Editor: React.FC<EditorProps> = ({
placeholder: t`Start writing, type "/" to list commands, or "@" to mention an item...`,
}),
CardEmbed,
FlexContainer,
SupportingText,
MentionExtension.configure({
suggestion: {
allow: ({ state }) => !isMetabotBlock(state),
@@ -138,6 +153,8 @@ export const Editor: React.FC<EditorProps> = ({
render: createSuggestionRenderer(MetabotMentionSuggestion),
},
}),
ResizeNode,
HandleEditorDrop,
],
[siteUrl, getState],
);

View File

@@ -1,6 +1,7 @@
.embedWrapper {
display: block;
width: 100%;
height: 100%;
}
.cardEmbed {
@@ -15,6 +16,8 @@
transition: border-color 0.2s ease;
display: flex;
flex-direction: column;
height: 100%;
background: var(--mb-color-background);
}
.cardEmbed.selected {
@@ -29,13 +32,18 @@
-ms-user-select: none;
}
.cardEmbed div[data-element-id="data-grid-measure-root"] {
display: none;
}
.questionHeader {
padding: 0.5rem 1rem;
}
.questionResults {
height: 400px;
position: relative;
flex-grow: 1;
overflow: hidden; /* needed to constraint funnel chart */
}
.loadingContainer {
@@ -51,11 +59,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 {
@@ -63,6 +79,37 @@
}
.menuButton {
cursor: pointer;
border: none;
border-radius: var(--mantine-radius-xs);
&:hover {
background-color: var(--mb-color-bg-light);
}
}
.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,5 +1,8 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import { Node, mergeAttributes } from "@tiptap/core";
import {
Node,
findParentNodeClosestToPos,
mergeAttributes,
} from "@tiptap/core";
import {
type NodeViewProps,
NodeViewWrapper,
@@ -7,13 +10,25 @@ import {
} from "@tiptap/react";
import cx from "classnames";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { push } from "react-router-redux";
import { useMount, useUnmount } from "react-use";
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";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Flex, Icon, Loader, Menu, Text, TextInput } from "metabase/ui";
import {
Box,
Button,
Flex,
Icon,
Loader,
Menu,
Text,
TextInput,
} from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import { ErrorView } from "metabase/visualizations/components/Visualization/ErrorView/ErrorView";
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
@@ -22,7 +37,10 @@ import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import { navigateToCardFromDocument } from "metabase-enterprise/documents/actions";
import { trackDocumentReplaceCard } from "metabase-enterprise/documents/analytics";
import { CommentsMenu } from "metabase-enterprise/documents/components/Editor/CommentsMenu";
import {
getUnresolvedComments,
useCommentsButton,
} from "metabase-enterprise/documents/components/Editor/CommentsMenu";
import { EDITOR_STYLE_BOUNDARY_CLASS } from "metabase-enterprise/documents/components/Editor/constants";
import {
loadMetadataForDocumentCard,
@@ -47,6 +65,38 @@ import styles from "./CardEmbedNode.module.css";
import { ModifyQuestionModal } from "./ModifyQuestionModal";
import { NativeQueryModal } from "./NativeQueryModal";
export const DROP_ZONE_COLOR = "var(--mb-base-color-blue-30)";
interface DropZoneProps {
isOver: boolean;
side: "left" | "right";
disabled?: boolean;
}
const DropZone = ({ isOver, side, disabled }: DropZoneProps) => {
if (disabled) {
return null;
}
return (
<Box
style={{
position: "absolute",
top: 0,
bottom: 0,
width: "0.25rem",
[side]: "-0.625rem",
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}" %}`;
@@ -77,6 +127,7 @@ export const CardEmbed: Node<{
atom: true,
draggable: true,
selectable: true,
disableDropCursor: true,
addAttributes() {
return {
@@ -143,9 +194,9 @@ export const CardEmbedComponent = memo(
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const { _id } = node.attrs;
const isOpen = childTargetId === _id;
const isHovered = hoveredChildTargetId === _id;
@@ -153,16 +204,27 @@ export const CardEmbedComponent = memo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
const unresolvedCommentsCount = useMemo(
() => getUnresolvedComments(threads).length,
[threads],
);
const {
component, // don't use Link component here since it messes with tiptap's link handling
to: commentsPath,
...commentsButtonProps
} = useCommentsButton({
active: isOpen,
disabled: hasUnsavedChanges,
href: document ? `/document/${document.id}/comments/${_id}` : "",
unresolvedCommentsCount,
});
const { id, name } = node.attrs;
const dispatch = useDispatch();
const canWrite = editor.options.editable;
const isMountedRef = useRef(false);
let embedIndex = -1;
if (editor && getPos) {
@@ -191,6 +253,38 @@ export const CardEmbedComponent = memo(
const titleInputRef = useRef<HTMLInputElement>(null);
const [isModifyModalOpen, setIsModifyModalOpen] = useState(false);
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
const [dragState, setDragState] = useState<{
isDraggedOver: boolean;
side: "left" | "right" | null;
}>({ isDraggedOver: false, side: null });
const draggedOverTimeoutRef = useRef<number | undefined>();
const cardEmbedRef = useRef<HTMLDivElement>(null);
/* TODO: Replace `3`s with constant */
/* TODO: Memoize? This component is currently re-rendering a lot (maybe because of a mouseover event?) */
const shouldAllowAddingSupportingText = () => {
const pos = getPos();
if (!pos) {
return false;
}
const resolvedPos = editor.state.doc.resolve(pos);
const match = findParentNodeClosestToPos(
resolvedPos,
(n) => n.type.name === "flexContainer",
);
if (!match) {
return true;
}
if (match.node.content.childCount >= 3) {
return false;
}
const hasSupportingText = match?.node.content.content.some(
(n) => n.type.name === "supportingText",
);
return !hasSupportingText;
};
const isBeingDragged = editor.view.draggingNode === node;
const displayName = name || card?.name;
const isNativeQuestion = card?.dataset_query?.type === "native";
@@ -202,6 +296,14 @@ export const CardEmbedComponent = memo(
}
}, [isEditingTitle]);
useMount(() => {
isMountedRef.current = true;
});
useUnmount(() => {
isMountedRef.current = false;
});
const handleTitleSave = () => {
const trimmedTitle = editedTitle.trim();
if (trimmedTitle && trimmedTitle !== card?.name) {
@@ -310,6 +412,68 @@ export const CardEmbedComponent = memo(
[dispatch, metadata, document],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const draggingNode = editor.view.draggingNode;
if (
draggingNode &&
draggingNode.type.name === "cardEmbed" &&
cardEmbedRef.current
) {
// Check if this cardEmbed is in a flexContainer that already has 3 children
const pos = getPos();
if (pos) {
const resolvedPos = editor.state.doc.resolve(pos);
const { parent } = resolvedPos;
if (
parent.type.name === "flexContainer" &&
parent.content.childCount >= 3
) {
let containsDraggedNode = false;
for (let i = 0; i < parent.content.childCount; i++) {
const child = parent.child(i);
containsDraggedNode =
containsDraggedNode || child === draggingNode;
}
if (!containsDraggedNode) {
// Don't show drop zones if flexContainer is already at max capacity
setDragState({ isDraggedOver: false, side: null });
return;
}
}
}
const rect = cardEmbedRef.current.getBoundingClientRect();
const relativeX = e.clientX - rect.left;
const nodeWidth = rect.width;
// Determine which side based on cursor position
let side: "left" | "right" | null = null;
if (relativeX < nodeWidth * 0.5) {
side = "left";
} else if (relativeX >= nodeWidth * 0.5) {
side = "right";
}
setDragState({ isDraggedOver: true, side });
window.clearTimeout(draggedOverTimeoutRef.current);
draggedOverTimeoutRef.current = window.setTimeout(() => {
if (isMountedRef.current) {
setDragState({ isDraggedOver: false, side: null });
}
}, 300);
}
},
[editor.view.draggingNode, getPos, editor.state.doc],
);
if (isLoading && !card) {
return (
<NodeViewWrapper
@@ -317,6 +481,7 @@ export const CardEmbedComponent = memo(
className={cx(styles.embedWrapper, CS.root, {
[CS.open]: isOpen || isHovered,
})}
style={{ position: "relative" }}
>
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
@@ -350,6 +515,7 @@ export const CardEmbedComponent = memo(
[CS.open]: isOpen || isHovered,
})}
data-testid="document-card-embed"
style={{ position: "relative" }}
>
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
@@ -378,11 +544,26 @@ export const CardEmbedComponent = memo(
[CS.open]: isOpen || isHovered,
})}
data-testid="document-card-embed"
ref={refs.setReference}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
data-drag-handle
onDragOver={handleDragOver}
onDrop={() => setDragState({ isDraggedOver: false, side: null })}
>
{canWrite && id && (
<>
<DropZone
isOver={dragState.isDraggedOver && dragState.side === "left"}
side="left"
disabled={isBeingDragged}
/>
<DropZone
isOver={dragState.isDraggedOver && dragState.side === "right"}
side="right"
disabled={isBeingDragged}
/>
</>
)}
<Box
ref={cardEmbedRef}
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
@@ -419,15 +600,19 @@ 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"
@@ -444,13 +629,23 @@ export const CardEmbedComponent = memo(
)}
</Box>
)}
{!isEditingTitle &&
document &&
unresolvedCommentsCount > 0 && (
<Box data-hide-on-print my="-sm" ml="auto">
<Button
{...commentsButtonProps}
onClick={() => {
commentsPath && dispatch(push(commentsPath));
}}
/>
</Box>
)}
{!isEditingTitle && (
<Menu withinPortal position="bottom-end" data-hide-on-print>
<Menu.Target>
<Flex
component="button"
bg="transparent"
c="pointer"
p="0.25rem"
align="center"
justify="center"
@@ -465,6 +660,59 @@ export const CardEmbedComponent = memo(
</Flex>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => {
commentsPath && dispatch(push(commentsPath));
}}
disabled={!commentsPath}
leftSection={<Icon name="add_comment" size={14} />}
>
{t`Comment`}
</Menu.Item>
<Menu.Item
onClick={async () => {
await Promise.resolve(); // Wait for the menu to close. The transaction below may cause this item to disable and the mouseup isn't registered (so the menu stays open).
const pos = getPos();
if (!pos) {
return false;
}
const resolvedPos = editor.state.doc.resolve(pos);
const match = findParentNodeClosestToPos(
resolvedPos,
(n) =>
n.type.name === "flexContainer" ||
n.type.name === "resizeNode",
);
if (!match) {
return;
}
const { schema, tr } = editor.view.state;
const supportingText =
schema.nodes.supportingText.create({}, [
schema.nodes.paragraph.create({}),
]);
if (match.node.type.name === "flexContainer") {
tr.insert(match.start, supportingText);
editor.view.dispatch(tr);
editor.commands.focus(match.start + 1);
return;
}
const flexContainer =
editor.view.state.schema.nodes.flexContainer.create(
{ columnWidths: [100 / 3, 200 / 3] },
[supportingText, node],
);
const endPos = match.start + match.node.nodeSize;
tr.replaceWith(match.start, endPos, flexContainer);
editor.view.dispatch(tr);
editor.commands.focus(match.start + 2);
}}
disabled={!shouldAllowAddingSupportingText()}
leftSection={<Icon name="add_list" size={14} />}
>
{t`Add supporting text`}
</Menu.Item>
<Menu.Item
onClick={handleEditVisualizationSettings}
disabled={!canWrite}
@@ -559,18 +807,6 @@ export const CardEmbedComponent = memo(
/>
)}
</NodeViewWrapper>
{document && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
threads={threads}
style={floatingStyles}
/>
)}
</>
);
},

View File

@@ -9,6 +9,8 @@ import {
} from "metabase-enterprise/documents/analytics";
import type { Document } from "metabase-types/api";
import { wrapCardEmbed } from "../shared/layout";
export interface CommandOptions {
suggestion: Partial<SuggestionOptions>;
}
@@ -63,12 +65,14 @@ export const CommandExtension = Extension.create<CommandOptions>({
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "cardEmbed",
attrs: {
id: props.entityId,
},
})
.insertContent(
wrapCardEmbed({
type: "cardEmbed",
attrs: {
id: props.entityId,
},
}),
)
.setTextSelection(range.from + 1)
.run();
if (props.document) {

View File

@@ -1,4 +1,4 @@
import type { Editor, Range } from "@tiptap/core";
import { type Editor, type Range, findParentNode } from "@tiptap/core";
import {
type DOMAttributes,
forwardRef,
@@ -70,6 +70,7 @@ interface CommandOption {
text?: string;
label: string;
command: string;
nodeName?: string;
}
interface CommandSection {
@@ -115,6 +116,28 @@ const CommandMenuItem = forwardRef<
);
});
const isCommandAllowedAtPosition =
(editor: Editor) =>
(item: CommandOption): boolean => {
const nodeType = item.nodeName && editor.schema.nodes[item.nodeName];
if (!nodeType) {
return true;
}
const closestIsolatingParent = findParentNode(
(n) => !!n.type.spec.isolating,
)(editor.state.selection);
if (!closestIsolatingParent) {
return true;
}
const wouldInsertedNodeBeAllowed = !!closestIsolatingParent?.node
.contentMatchAt(0)
.matchType(nodeType);
return wouldInsertedNodeBeAllowed;
};
export const CommandSuggestion = forwardRef<
SuggestionRef,
CommandSuggestionProps
@@ -130,25 +153,27 @@ export const CommandSuggestion = forwardRef<
{
items: [
...(PLUGIN_METABOT.isEnabled()
? ([
? [
{
icon: "metabot",
icon: "metabot" as const,
label: t`Ask Metabot`,
command: "metabot",
nodeName: "metabot",
},
] as const)
]
: []),
{
icon: "lineandbar",
icon: "lineandbar" as const,
label: t`Chart`,
command: "embedQuestion",
nodeName: "cardEmbed",
},
{
icon: "link",
icon: "link" as const,
label: t`Link`,
command: "linkTo",
},
],
].filter(isCommandAllowedAtPosition(editor)),
},
{
title: t`Formatting`,
@@ -191,7 +216,7 @@ export const CommandSuggestion = forwardRef<
],
},
],
[],
[editor],
);
const allCommandOptions = useMemo(

View File

@@ -1,4 +1,8 @@
import type { AnyExtension } from "@tiptap/core";
import {
type AnyExtension,
callOrReturn,
getExtensionField,
} from "@tiptap/core";
import { Blockquote } from "@tiptap/extension-blockquote";
import { Bold } from "@tiptap/extension-bold";
import { BulletList } from "@tiptap/extension-bullet-list";
@@ -15,6 +19,13 @@ import { CustomHeading } from "../Heading";
import { CustomOrderedList } from "../OrderedList";
import { CustomParagraph } from "../Paragraph";
declare module "@tiptap/core" {
// This adds a new configuration option to the NodeConfig
interface NodeConfig {
disableDropCursor?: boolean | null;
}
}
const CustomBold = Bold.extend({
addKeyboardShortcuts() {
// "Mod-b" (sans shift key) is fine, "Mod-B" (with shift key) should not be hijacked
@@ -105,4 +116,20 @@ export const CustomStarterKit = StarterKit.extend<StarterKitOptions>({
return extensions;
},
// fixup for tiptap's wrapper over https://github.com/ProseMirror/prosemirror-dropcursor to enable "disableDropCursor" setting
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
};
return {
disableDropCursor:
callOrReturn(
getExtensionField(extension, "disableDropCursor", context),
) ?? null,
};
},
});

View File

@@ -0,0 +1,93 @@
.flexContainer {
display: block;
width: 100%;
margin: 1rem 0;
border-radius: var(--mantine-radius-md);
position: relative;
max-width: 900px;
height: 100%;
caret-color: transparent;
}
.flexContainer.selected {
outline: 2px solid var(--mb-color-brand);
outline-offset: 2px;
}
.flexContent > :global(div[data-node-view-wrapper]) {
display: grid;
width: 100%;
height: 100%;
grid-template-columns: var(--mb-card-container-col-widths);
}
.flexContent {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.flexContent :global(.node-cardEmbed) :global(.editor-style-boundary) {
margin: 0;
}
.flexContent :global(.node-cardEmbed) {
height: 100%;
padding-left: 0.5rem;
padding-right: 0.5rem;
caret-color: auto;
transform: translateZ(0);
}
.flexContent :global(.node-cardEmbed):first-child {
padding-left: 0;
}
.flexContent :global(.node-cardEmbed):last-child {
padding-right: 0;
}
/* 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,253 @@
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 { useCallback, useMemo, useRef, useState } from "react";
import { Box } from "metabase/ui";
import styles from "./FlexContainer.module.css";
const COLUMN_MIN_WIDTH = 200;
export interface FlexContainerAttributes {
class?: string;
columnWidths?: number[]; // Array of width percentages for each column
}
export const FlexContainer: Node<{
HTMLAttributes: FlexContainerAttributes;
}> = Node.create({
name: "flexContainer",
group: "block",
content: "(supportingText | cardEmbed){1,3}", // Contains 1-3 CardEmbed nodes only
defining: true,
draggable: false,
selectable: false,
disableDropCursor: true,
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,
];
},
addNodeView() {
return ReactNodeViewRenderer(FlexContainerComponent);
},
});
const FlexContainerComponent: React.FC<NodeViewProps> = ({
node,
updateAttributes,
selected,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const dndContext = useDndContext();
const isDragging = dndContext.active !== null;
// Get column count and current widths
const columnCount = node.content.childCount;
const currentWidths: number[] = 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]);
const handleMouseDown = useCallback(
(handleIndex: number, e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const container = containerRef.current;
if (!container) {
return;
}
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);
if (Math.abs(totalWidth - 100) > 0.01) {
// Normalize to ensure exact 100%
const normalizedWidths = newWidths.map(
(width) => (width / totalWidth) * 100,
);
updateAttributes({ columnWidths: normalizedWidths });
} else {
updateAttributes({ columnWidths: newWidths });
}
}
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[columnWidths, updateAttributes],
);
const renderResizeHandles = (disabled: boolean) => {
if (columnCount <= 1) {
return null;
}
const handles = [];
for (let i = 0; i < columnCount - 1; i++) {
// Calculate the cumulative width of columns before this handle
const leftWidth = columnWidths
.slice(0, i + 1)
.reduce((sum: number, width: number) => sum + width, 0);
handles.push(
<div
key={i}
className={styles.resizeHandle}
style={{
left: `${leftWidth}%`,
pointerEvents: disabled ? "none" : "auto",
}}
contentEditable={false}
onMouseDown={(e) => handleMouseDown(i, e)}
/>,
);
}
return handles;
};
return (
<NodeViewWrapper
className={cx(styles.flexContainer, {
[styles.selected]: selected,
[styles.resizing]: isResizing,
})}
data-type="flexContainer"
>
<Box h="100%" ref={containerRef}>
<NodeViewContent
className={styles.flexContent}
style={{
// @ts-expect-error - custom CSS variable needed for this component
"--mb-card-container-col-widths": columnWidths
.map(
(width: number) => `minmax(${COLUMN_MIN_WIDTH}px, ${width}%)`,
)
.join(" "),
}}
/>
{renderResizeHandles(isDragging)}
</Box>
</NodeViewWrapper>
);
};
FlexContainerComponent.displayName = "FlexContainerComponent";

View File

@@ -0,0 +1,827 @@
import { Extension } from "@tiptap/core";
import {
Fragment,
type Node,
type NodeType as PMNodeType,
Slice,
} from "@tiptap/pm/model";
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
import type { EditorView as PMEditorView } from "@tiptap/pm/view";
import type { NodeViewProps } from "@tiptap/react";
import {
RESIZE_NODE_DEFAULT_HEIGHT,
RESIZE_NODE_MIN_HEIGHT,
} from "metabase-enterprise/rich_text_editing/tiptap/extensions/ResizeNode/ResizeNode";
import {
type DroppedCardEmbedNodeData,
extractCardEmbed,
getCardEmbedDropSide,
getDroppedCardEmbedNodeData,
getTargetCardEmbedNode,
} from "./utils";
declare module "prosemirror-view" {
// This adds a new configuration option to the NodeConfig
class EditorView {
draggingNode?: NodeViewProps["node"] | null;
}
}
export const HandleEditorDrop = Extension.create({
name: "handleEditorDrop",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("metabaseTiptapDrop"),
props: {
// eslint-disable-next-line complexity
handleDrop: (view, e, slice, moved) => {
const cardEmbedInitialData = getDroppedCardEmbedNodeData(
view,
e,
slice,
moved,
);
if (cardEmbedInitialData) {
const {
cardEmbedNode,
originalPos,
originalParent,
cameFromFlexContainer,
dropPos,
dropToParentPos,
dropToParent,
} = cardEmbedInitialData;
// Dropping inside a flexContainer
if (dropToParent.type.name === "flexContainer") {
// Handle dropping within the same FlexContainer - columns reordering
if (cameFromFlexContainer && originalParent === dropToParent) {
const targetCardEmbedNode = getTargetCardEmbedNode(e, view);
if (!targetCardEmbedNode) {
return;
}
if (targetCardEmbedNode === cardEmbedNode) {
return true; // Prevent dropping on itself
}
// Moving within the same FlexContainer - reorder cards
const tr = view.state.tr;
// Find the source index
const sourceIndex = view.state.doc
.resolve(originalPos)
.index();
// Calculate the intended insertion index based on drop position
let targetIndex =
dropToParent.content.content.indexOf(targetCardEmbedNode);
const dropSide = getCardEmbedDropSide(e);
if (dropSide === "left" && targetIndex > 1) {
targetIndex--;
} else if (dropSide === "right") {
targetIndex++;
}
// Create array of all children except the dragged one
const allChildren = [];
for (let i = 0; i < dropToParent.childCount; i++) {
if (i !== sourceIndex) {
const child = dropToParent.child(i);
const childCardEmbed = extractCardEmbed(child);
allChildren.push(childCardEmbed ? childCardEmbed : child);
}
}
// Adjust targetIndex for the removed element
// Since we removed the source element, we need to adjust the target index
let adjustedTargetIndex;
if (targetIndex > sourceIndex) {
// Moving right: the target index shifts down by 1 because we removed an element before it
adjustedTargetIndex = targetIndex - 1;
} else {
// Moving left: target index stays the same
adjustedTargetIndex = targetIndex;
}
// But for moving from left (0) to right of right card, we actually want to append
// In our case: sourceIndex=0, targetIndex=1, so we want index 1 in the reduced array
// which means "after the remaining element"
if (
sourceIndex === 0 &&
targetIndex === 1 &&
allChildren.length === 1
) {
adjustedTargetIndex = 1; // Insert after the remaining element
}
// Insert the dragged element at the correct position
allChildren.splice(adjustedTargetIndex, 0, cardEmbedNode);
const newContainer =
view.state.schema.nodes.flexContainer.create(
dropToParent.attrs,
allChildren,
);
const containerPos = dropToParentPos.before();
tr.replaceWith(
containerPos,
containerPos + dropToParent.nodeSize,
newContainer,
);
view.dispatch(tr);
return true;
}
// Handle dropping from one FlexContainer to another FlexContainer
if (cameFromFlexContainer && originalParent !== dropToParent) {
const targetFlexContainer = dropToParent;
// Check if target flexContainer already has 3 or more cards
if (targetFlexContainer.content.childCount >= 3) {
return true; // Don't allow dropping if target already has 3 cards
}
const targetCardEmbedNode = getTargetCardEmbedNode(e, view);
if (!targetCardEmbedNode) {
return;
}
// Calculate the intended insertion index based on drop position
let targetIndex =
dropToParent.content.content.indexOf(targetCardEmbedNode);
const dropSide = getCardEmbedDropSide(e);
if (dropSide === "left" && targetIndex > 1) {
targetIndex--;
} else if (dropSide === "right") {
targetIndex++;
}
// Create transaction
const tr = view.state.tr;
// First, remove the card from the source flexContainer
const sourceFlexContainer = originalParent;
const sourceIndex = view.state.doc
.resolve(originalPos)
.index();
const sourceNewChildren = [];
for (let i = 0; i < sourceFlexContainer.childCount; i++) {
if (i !== sourceIndex) {
const child = sourceFlexContainer.child(i);
sourceNewChildren.push(child);
}
}
// Find source flexContainer position
let sourceFlexContainerPos: number | null = null;
view.state.doc.descendants((node, nodePos) => {
if (node === sourceFlexContainer) {
sourceFlexContainerPos = nodePos;
return false;
}
});
if (sourceFlexContainerPos === null) {
return;
}
// Update source flexContainer or remove it if empty
if (sourceNewChildren.length === 1) {
// Only one card left in source - unwrap it from flexContainer
const remainingChild = sourceNewChildren[0];
const remainingCardEmbed = extractCardEmbed(remainingChild);
if (remainingCardEmbed) {
const wrappedRemainingCard =
view.state.schema.nodes.resizeNode.create(
{
height: RESIZE_NODE_DEFAULT_HEIGHT,
minHeight: RESIZE_NODE_MIN_HEIGHT,
},
[remainingCardEmbed],
);
// Replace the source flexContainer with unwrapped card
const sourceContainerResolvedPos = view.state.doc.resolve(
sourceFlexContainerPos,
);
const sourceContainerParent =
sourceContainerResolvedPos.parent;
if (sourceContainerParent.type.name === "resizeNode") {
// FlexContainer is wrapped in resizeNode, replace the entire wrapper
const sourceWrapperPos =
sourceContainerResolvedPos.before();
tr.replaceWith(
sourceWrapperPos,
sourceWrapperPos + sourceContainerParent.nodeSize,
wrappedRemainingCard,
);
} else {
// FlexContainer is not wrapped, just replace it
tr.replaceWith(
sourceFlexContainerPos,
sourceFlexContainerPos + sourceFlexContainer.nodeSize,
wrappedRemainingCard,
);
}
}
} else if (sourceNewChildren.length > 1) {
// Multiple cards left in source, keep as flexContainer
const newSourceFlexContainer =
view.state.schema.nodes.flexContainer.create(
sourceFlexContainer.attrs,
sourceNewChildren,
);
tr.replaceWith(
sourceFlexContainerPos,
sourceFlexContainerPos + sourceFlexContainer.nodeSize,
newSourceFlexContainer,
);
} else {
// No cards left, remove the entire source flexContainer and its wrapper
const sourceContainerResolvedPos = view.state.doc.resolve(
sourceFlexContainerPos,
);
const sourceContainerParent =
sourceContainerResolvedPos.parent;
if (sourceContainerParent.type.name === "resizeNode") {
// Remove the entire resizeNode wrapper
const sourceWrapperPos =
sourceContainerResolvedPos.before();
tr.delete(
sourceWrapperPos,
sourceWrapperPos + sourceContainerParent.nodeSize,
);
} else {
// Remove just the flexContainer
tr.delete(
sourceFlexContainerPos,
sourceFlexContainerPos + sourceFlexContainer.nodeSize,
);
}
}
// Now add the card to the target flexContainer
const targetChildren: Node[] = [];
for (
let i = 0;
i < targetFlexContainer.content.childCount;
i++
) {
const child = targetFlexContainer.content.child(i);
targetChildren.push(child);
}
targetChildren.splice(targetIndex, 0, cardEmbedNode.copy());
// Find target flexContainer position
let targetFlexContainerPos: number | null = null;
view.state.doc.descendants((node, nodePos) => {
if (node === targetFlexContainer) {
targetFlexContainerPos = nodePos;
return false;
}
});
if (targetFlexContainerPos === null) {
return;
}
// Replace target flexContainer with updated one
const newTargetFlexContainer =
view.state.schema.nodes.flexContainer.create(
targetFlexContainer.attrs,
targetChildren,
);
// Since we may have modified the document above, recalculate the target position
const updatedDoc = tr.doc;
let updatedTargetPos: number | null = null;
updatedDoc.descendants((node, nodePos) => {
if (node === targetFlexContainer) {
updatedTargetPos = nodePos;
return false;
}
});
if (updatedTargetPos !== null) {
tr.replaceWith(
updatedTargetPos,
updatedTargetPos + targetFlexContainer.nodeSize,
newTargetFlexContainer,
);
}
view.dispatch(tr);
return true;
}
// Handle dropping on a cardEmbed that is already in a flexContainer
if (!cameFromFlexContainer) {
const flexContainer = dropToParent;
if (flexContainer.content.childCount === 3) {
return true; // Don't allow more than 3 cards in flexContainer
}
const targetCardEmbedNode = getTargetCardEmbedNode(e, view);
if (!targetCardEmbedNode) {
return;
}
// Calculate the intended insertion index based on drop position
let targetIndex =
dropToParent.content.content.indexOf(targetCardEmbedNode);
const dropSide = getCardEmbedDropSide(e);
if (dropSide === "left" && targetIndex > 1) {
targetIndex--;
} else if (dropSide === "right") {
targetIndex++;
}
// Get all current children as cardEmbeds
const newChildren: Node[] = [];
for (let i = 0; i < flexContainer.content.childCount; i++) {
const child = flexContainer.content.child(i);
newChildren.push(child);
}
newChildren.splice(targetIndex, 0, cardEmbedNode.copy());
// Find the position of the flexContainer (it should be wrapped in resizeNode)
let flexContainerPos: number | null = null;
view.state.doc.descendants((node, nodePos) => {
if (node === flexContainer) {
flexContainerPos = nodePos;
return false;
}
});
if (flexContainerPos === null) {
return;
}
// Create transaction
const tr = view.state.tr;
// First, replace the flexContainer with the new one containing the inserted card
const newFlexContainer =
view.state.schema.nodes.flexContainer.create(
flexContainer.attrs,
newChildren,
);
tr.replaceWith(
flexContainerPos,
flexContainerPos + flexContainer.nodeSize,
newFlexContainer,
);
// Now remove the dropped node from its original position
// We need to find it again in the updated document
const nodeToRemove =
originalParent.type.name === "resizeNode"
? originalParent
: cardEmbedNode;
const updatedDoc = tr.doc;
let nodeFound = false;
updatedDoc.descendants((node, nodePos) => {
if (node === nodeToRemove && !nodeFound) {
tr.delete(nodePos, nodePos + node.nodeSize);
nodeFound = true;
return false;
}
});
view.dispatch(tr);
return true;
}
return false;
}
const targetCardEmbed = extractCardEmbed(dropToParent);
// Dropping on another cardEmbed
if (
targetCardEmbed &&
targetCardEmbed.type.name === "cardEmbed"
) {
if (targetCardEmbed === cardEmbedNode) {
return true; // Prevent dropping on itself
}
const dropSide = getCardEmbedDropSide(e);
// Create new FlexContainer when dropping on a standalone CardEmbed
const children =
dropSide === "left"
? [cardEmbedNode.copy(), targetCardEmbed.copy()]
: [targetCardEmbed.copy(), cardEmbedNode.copy()];
const flexContainer =
view.state.schema.nodes.flexContainer.create({}, children);
// Wrap the flexContainer in a resizeNode
const wrapper = view.state.schema.nodes.resizeNode.create(
{
height: RESIZE_NODE_DEFAULT_HEIGHT,
minHeight: RESIZE_NODE_MIN_HEIGHT,
},
[flexContainer],
);
// Determine what to replace - if target is wrapped in resizeNode, replace the wrapper
let replacePos = originalPos;
let replaceSize = cardEmbedNode.nodeSize;
if (dropToParent.type.name === "resizeNode") {
// Target is wrapped, replace the entire resizeNode
replacePos = dropToParentPos.before();
replaceSize = dropToParent.nodeSize;
}
// Create a single transaction for both operations to avoid position shifting
const tr = view.state.tr;
// First, find and remove the dropped node from its original position
const nodeToRemove =
originalParent.type.name === "resizeNode"
? originalParent
: cardEmbedNode;
let removalHandled = false;
let removePos: number | null = null;
view.state.doc.descendants((node, nodePos) => {
if (node === nodeToRemove && !removalHandled) {
tr.delete(nodePos, nodePos + node.nodeSize);
removalHandled = true;
removePos = nodePos;
return false;
}
});
// Then replace the target with the new FlexContainer
// Need to recalculate positions after the removal
let adjustedReplacePos = replacePos;
if (
removalHandled &&
removePos !== null &&
removePos < replacePos
) {
// If we removed something before the replace position, adjust it
const removedSize = nodeToRemove.nodeSize;
adjustedReplacePos = replacePos - removedSize;
}
tr.replaceWith(
adjustedReplacePos,
adjustedReplacePos + replaceSize,
wrapper,
);
if (cameFromFlexContainer) {
// TODO: check if we need to clean up the original flexContainer if it had only 1 card left
}
view.dispatch(tr);
return true;
}
if (dropToParent.type.name === "paragraph") {
return handleCardDropOnParagraph(
cardEmbedInitialData,
cameFromFlexContainer,
);
}
// Check if dropping into document (not into another flexContainer or cardEmbed)
if (
dropToParent.type.name !== "flexContainer" &&
dropToParent.type.name !== "cardEmbed" &&
dropToParent.type.name !== "resizeNode"
) {
if (cameFromFlexContainer) {
// Handle moving from FlexContainer to document
if (
originalPos !== null &&
originalParent?.type.name === "flexContainer"
) {
const tr = view.state.tr;
// First, update/remove from FlexContainer (do this BEFORE insertion to avoid position shifting)
const flexContainerPos = view.state.doc
.resolve(originalPos)
.before();
const flexContainer = originalParent;
// Remove the card from the FlexContainer
const newChildren = [];
const sourceIndex = view.state.doc
.resolve(originalPos)
.index();
for (let i = 0; i < flexContainer.childCount; i++) {
if (i !== sourceIndex) {
const child = flexContainer.child(i);
newChildren.push(child);
}
}
if (newChildren.length === 1) {
// If only one card left, unwrap it from FlexContainer and wrap in resizeNode
const remainingChild = newChildren[0];
const remainingCardEmbed =
extractCardEmbed(remainingChild);
if (remainingCardEmbed) {
const wrappedRemainingCard =
view.state.schema.nodes.resizeNode.create(
{
height: RESIZE_NODE_DEFAULT_HEIGHT,
minHeight: RESIZE_NODE_MIN_HEIGHT,
},
[remainingCardEmbed],
);
// Replace the entire FlexContainer (and its resizeNode wrapper if it exists)
const containerResolvedPos =
view.state.doc.resolve(flexContainerPos);
const containerParent = containerResolvedPos.parent;
if (containerParent.type.name === "resizeNode") {
// FlexContainer is wrapped in resizeNode, replace the entire wrapper
const wrapperPos = containerResolvedPos.before();
tr.replaceWith(
wrapperPos,
wrapperPos + containerParent.nodeSize,
wrappedRemainingCard,
);
} else {
// FlexContainer is not wrapped, just replace it
tr.replaceWith(
flexContainerPos,
flexContainerPos + flexContainer.nodeSize,
wrappedRemainingCard,
);
}
}
} else if (newChildren.length > 1) {
// Multiple cards left, keep as FlexContainer
const newFlexContainer =
view.state.schema.nodes.flexContainer.create(
flexContainer.attrs,
newChildren,
);
tr.replaceWith(
flexContainerPos,
flexContainerPos + flexContainer.nodeSize,
newFlexContainer,
);
} else {
// No cards left, remove the entire FlexContainer and its wrapper
const containerResolvedPos =
view.state.doc.resolve(flexContainerPos);
const containerParent = containerResolvedPos.parent;
if (containerParent.type.name === "resizeNode") {
// Remove the entire resizeNode wrapper
const wrapperPos = containerResolvedPos.before();
tr.delete(
wrapperPos,
wrapperPos + containerParent.nodeSize,
);
} else {
// Remove just the FlexContainer
tr.delete(
flexContainerPos,
flexContainerPos + flexContainer.nodeSize,
);
}
}
// Now insert the wrapped node at drop position (after FlexContainer operations)
const wrappedNode =
view.state.schema.nodes.resizeNode.create(
{
height: RESIZE_NODE_DEFAULT_HEIGHT,
minHeight: RESIZE_NODE_MIN_HEIGHT,
},
[cardEmbedNode],
);
// Calculate adjusted drop position if the FlexContainer operations affected positions
let adjustedDropPos = dropPos;
if (dropPos > flexContainerPos) {
// If dropping after the FlexContainer, positions might have shifted
// This is complex to calculate, so let's recalculate
const coords = view.posAtCoords({
left: e.clientX,
top: e.clientY,
});
if (coords) {
adjustedDropPos = tr.doc.resolve(coords.pos).pos;
}
}
tr.insert(adjustedDropPos, wrappedNode);
view.dispatch(tr);
return true;
}
} else {
// Handle general case: cardEmbed being moved within document or from resizeNode
// Always ensure cardEmbed is wrapped in resizeNode when dropped in document
const tr = view.state.tr;
// Wrap the cardEmbed in a resizeNode
const wrappedNode = view.state.schema.nodes.resizeNode.create(
{
height: RESIZE_NODE_DEFAULT_HEIGHT,
minHeight: RESIZE_NODE_MIN_HEIGHT,
},
[cardEmbedNode],
);
// Remove the original node from its position
if (originalPos !== null) {
// Find the actual node to remove (could be the cardEmbed itself or its resizeNode wrapper)
view.state.doc.descendants((node, pos) => {
if (
node === originalParent ||
extractCardEmbed(node) === cardEmbedNode
) {
// Remove the node (whether it's a standalone cardEmbed or wrapped in resizeNode)
tr.delete(pos, pos + node.nodeSize);
return false;
}
});
}
// Insert wrapped node at drop position
tr.insert(dropPos, wrappedNode);
view.dispatch(tr);
return true;
}
}
}
// Return false to allow default drop behavior for other cases
return false;
},
handleDOMEvents: {
dragstart: (view, event) => {
const maybePos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (maybePos) {
const { pos } = maybePos;
const node = view.state.doc.nodeAt(pos);
view.draggingNode = node;
return false;
}
},
},
handleTextInput: (view) => {
const { state } = view;
const { selection } = state;
// Check if we have a node selection on a cardEmbed
if (selection instanceof NodeSelection) {
const selectedNode = selection.node;
if (selectedNode.type.name === "cardEmbed") {
// Ignore text input when a cardEmbed is selected
return true; // Prevent default behavior
}
}
},
transformPasted: (slice, view) => {
const { content } = slice.content;
const isPastingCardEmbed =
content.length === 1 && content[0]?.type.name === "cardEmbed";
if (!isPastingCardEmbed) {
return slice;
}
const { state } = view;
const resolvedPos = state.doc.resolve(state.selection.from);
const isTopLevelParagraph =
resolvedPos.parent.type.name === "paragraph" &&
resolvedPos.depth === 1;
if (!isTopLevelParagraph) {
return slice;
}
const transformedContent = slice.content.content.map((node) => {
return state.schema.nodes.resizeNode.create({}, [node]);
});
return new Slice(
Fragment.fromArray(transformedContent),
slice.openStart,
slice.openEnd,
);
},
},
}),
];
},
});
const handleCardDropOnParagraph = (
{ originalPos, view, dropToParentPos, event }: DroppedCardEmbedNodeData,
wrapCardWithResizeNode: boolean,
) => {
const resolvedPos = dropToParentPos;
// Get the DOM element of the paragraph.
const paragraphDOM = view.domAtPos(resolvedPos.start()).node as Element;
const rect = paragraphDOM.getBoundingClientRect();
// If dropping in the upper half of the paragraph, insert before; else, insert after.
const insertBefore = event.clientY < rect.top + rect.height / 2;
const targetPos = insertBefore ? resolvedPos.start() : resolvedPos.end();
// Create a transaction that inserts the slice at the computed target position.
moveNode(
view,
originalPos,
targetPos,
wrapCardWithResizeNode ? view.state.schema.nodes.resizeNode : undefined,
);
// If we have moved content out of a flex container, we may need to unwrap an unneeded flex container
cleanupFlexContainerNodes(view);
return true; // Indicate that we've handled the drop.
};
const moveNode = (
view: PMEditorView,
fromPos: number,
toPos: number,
wrapper?: PMNodeType,
) => {
const { state } = view;
const { tr, doc } = state;
// Get the node at the fromPos.
const node = doc.nodeAt(fromPos);
if (!node) {
console.error("No node found at position", fromPos);
return;
}
const possiblyWrappedNode = wrapper ? wrapper.create({}, [node]) : node;
// Calculate the range: from the start of the node to its end.
const start = fromPos;
const end = fromPos + node.nodeSize;
// Delete the node from its current location.
tr.delete(start, end);
// Insert it at the new desired position.
//
// Note: Because you've already deleted the node, the toPos might need adjustment.
// A simple approach is to calculate the new target position relative to the deletion.
// If the node is moved to a location that comes after its original position,
// subtract the node size from the target.
const adjustedToPos = toPos > start ? toPos - node.nodeSize : toPos;
tr.insert(adjustedToPos, possiblyWrappedNode);
view.dispatch(tr);
};
// Traverses the document and unwraps any flexContainer nodes that only have 1 child
const cleanupFlexContainerNodes = (view: PMEditorView) => {
view.state.doc.descendants((node, pos) => {
if (node.type.name === "flexContainer" && node.childCount === 1) {
const child = node.firstChild;
if (child) {
view.dispatch(
view.state.tr.replaceWith(pos, pos + node.nodeSize, child),
);
}
}
});
};

View File

@@ -0,0 +1,142 @@
import type { Node, ResolvedPos, Slice } from "@tiptap/pm/model";
import type { EditorView } from "@tiptap/pm/view";
// Helper function to extract cardEmbed from resizeNode wrapper
export const extractCardEmbed = (node: Node): Node | null => {
if (node.type.name === "cardEmbed") {
return node;
}
if (node.type.name === "resizeNode" && node.content.childCount === 1) {
const child = node.content.child(0);
if (child.type.name === "cardEmbed") {
return child;
}
}
return null;
};
export const getCardEmbedDropSide = (e: DragEvent): "left" | "right" | null => {
const targetCardEmbedDOM =
e.target instanceof Element ? e.target.closest(".node-cardEmbed") : null;
if (targetCardEmbedDOM) {
const rect = targetCardEmbedDOM.getBoundingClientRect();
const xPct = (e.clientX - rect.left) / rect.width;
return xPct >= 0.5 ? "right" : "left";
}
return null;
};
export const getTargetCardEmbedNode = (e: DragEvent, view: EditorView) => {
const targetCardEmbedDOM =
e.target instanceof Element ? e.target.closest(".node-cardEmbed") : null;
if (targetCardEmbedDOM) {
const pos = view.posAtDOM(targetCardEmbedDOM, 0);
const targetCardEmbed = view.state.doc.nodeAt(pos);
if (targetCardEmbed) {
return extractCardEmbed(targetCardEmbed);
}
}
return null;
};
export interface DroppedCardEmbedNodeData {
cardEmbedNode: Node;
originalPos: number;
originalParent: Node;
cameFromFlexContainer: boolean;
dropPos: number;
dropToParentPos: ResolvedPos;
dropToParent: Node;
event: DragEvent;
slice: Slice;
moved: boolean;
view: EditorView;
}
export const getDroppedCardEmbedNodeData = (
view: EditorView,
event: DragEvent,
slice: Slice,
moved: boolean,
): DroppedCardEmbedNodeData | undefined => {
// Check if we're moving a cardEmbed node
if (slice.content.childCount === 1) {
const droppedNode = slice.content.child(0);
const cardEmbedNode = extractCardEmbed(droppedNode);
// Dropping a "cardEmbed" node
if (cardEmbedNode && moved) {
const nodeParentResult = findNodeParentAndPos(view, cardEmbedNode);
if (!nodeParentResult) {
return;
}
const { parent: originalParent, parentPos: originalPos } =
nodeParentResult;
const cameFromFlexContainer = originalParent
? (originalParent as Node).type.name === "flexContainer"
: false;
// Find the position where the card was dropped
const coords = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coords) {
const dropPos = coords.pos;
const dropToParentPos = view.state.doc.resolve(dropPos);
const dropToParent = dropToParentPos.parent;
return {
cardEmbedNode,
originalPos,
originalParent,
cameFromFlexContainer,
dropPos,
dropToParentPos,
dropToParent,
event,
slice,
view,
moved,
};
}
}
}
};
export const findNodeParentAndPos = (
view: EditorView,
nodeToFind: Node,
): { parentPos: number; parent: Node } | null => {
let parentPos: number | null = null;
let parent: Node | null = null;
view.state.doc.descendants((node, pos, nodeParent) => {
if (node === nodeToFind) {
parentPos = pos;
parent = nodeParent;
return false;
}
if (extractCardEmbed(node) === nodeToFind && node !== nodeToFind) {
parentPos = pos;
parent = node;
return false;
}
});
if (parentPos && parent) {
return { parentPos, parent };
}
return null;
};

View File

@@ -26,6 +26,8 @@ import { getCurrentDocument } from "metabase-enterprise/documents/selectors";
import MetabotThinkingStyles from "metabase-enterprise/metabot/components/MetabotChat/MetabotThinking.module.css";
import type { Card, MetabotGenerateContentRequest } from "metabase-types/api";
import { wrapCardEmbed } from "../shared/layout";
import S from "./MetabotEmbed.module.css";
declare module "@tiptap/core" {
@@ -233,12 +235,12 @@ export const MetabotComponent = memo(
);
editor.commands.insertContentAt(nodePosition, [
{
wrapCardEmbed({
type: "cardEmbed",
attrs: {
id: newCardId,
},
},
}),
{
type: "paragraph",
content: [createTextNode(data.description)],

View File

@@ -0,0 +1,45 @@
.content {
& > [data-node-view-content-react] {
height: 100%;
transform: translateZ(0);
& > :global(.node-cardEmbed) {
height: 100%;
}
& > :global(.node-flexContainer) {
height: 100%;
}
}
height: 100%;
padding-bottom: 1rem;
}
.dragContainer {
position: absolute;
bottom: 0.25rem;
width: 100%;
opacity: 0;
transition: opacity 150ms ease;
}
.dragHandle {
background-color: var(--mb-color-text-secondary-inverse);
height: 0.25rem;
width: 4rem;
cursor: row-resize;
z-index: 10;
border-radius: var(--default-border-radius);
}
.wrapper {
position: relative;
cursor: default;
&:hover {
.dragContainer {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,148 @@
import { Node, type NodeViewProps, mergeAttributes } from "@tiptap/core";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import type React from "react";
import { useRef, useState } from "react";
import { Box, Flex } from "metabase/ui";
import S from "./ResizeNode.module.css";
export const RESIZE_NODE_MIN_HEIGHT = 280;
export const RESIZE_NODE_DEFAULT_HEIGHT = 442;
export const ResizeNode: Node<{
HTMLAttributes: {
height: number;
minHeight: number;
};
}> = Node.create({
name: "resizeNode",
group: "block",
content: "(flexContainer | cardEmbed)",
atom: true,
draggable: false,
selectable: false,
disableDropCursor: true,
addAttributes() {
return {
height: {
default: RESIZE_NODE_DEFAULT_HEIGHT,
parseHTML: (element) => {
const rawValue = element.getAttribute("data-height");
return (rawValue && parseInt(rawValue, 10)) || null;
},
},
minHeight: {
default: RESIZE_NODE_MIN_HEIGHT,
parseHTML: (element) => {
const rawValue = element.getAttribute("data-min-height");
return (rawValue && parseInt(rawValue, 10)) || null;
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${ResizeNode.name}"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"div",
mergeAttributes(
HTMLAttributes,
{
"data-type": ResizeNode.name,
"data-height": node.attrs.height,
"data-min-height": node.attrs.minHeight,
},
this.options.HTMLAttributes,
),
0,
];
},
addNodeView() {
return ReactNodeViewRenderer(ResizeNodeView);
},
});
const ResizeNodeView = ({
node: { attrs },
updateAttributes,
}: NodeViewProps) => {
const { minHeight } = attrs;
const [height, setHeight] = useState(attrs.height);
const _height = useRef(attrs.height);
const computeHeight = (delta: number) => {
const newHeight = _height.current + delta;
return newHeight < minHeight ? minHeight : newHeight;
};
const handleDrag = (delta: number) => {
setHeight(computeHeight(delta));
};
const handleDragEnd = (delta: number) => {
const newHeight = computeHeight(delta);
setHeight(newHeight);
updateAttributes({
height: newHeight,
});
_height.current = newHeight;
};
return (
<NodeViewWrapper style={{ height }} className={S.wrapper}>
<NodeViewContent className={S.content} />
<DragHandle onDrag={handleDrag} onDragEnd={handleDragEnd} />
</NodeViewWrapper>
);
};
const DragHandle = ({
onDrag,
onDragEnd,
}: {
onDrag: (val: number) => void;
onDragEnd: (val: number) => void;
}) => {
const startY = useRef<number | null>(null);
const handleMouseDown = (e: React.MouseEvent) => {
startY.current = e.pageY;
const handleMouseMove = (e: MouseEvent) => {
if (startY.current) {
const delta = e.pageY - startY.current;
onDrag?.(delta);
}
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", (e) => {
window.removeEventListener("mousemove", handleMouseMove);
if (startY.current) {
onDragEnd?.(e.pageY - startY.current);
startY.current = null;
}
});
};
return (
<Flex justify="center" className={S.dragContainer} contentEditable={false}>
<Box className={S.dragHandle} onMouseDown={handleMouseDown} />
</Flex>
);
};

View File

@@ -0,0 +1,47 @@
.wrapper {
height: 100%;
position: relative;
border-radius: var(--mantine-radius-md);
overflow: hidden;
background-color: var(--mb-color-bg-light);
border: 1px solid transparent;
transition:
background-color 150ms,
border-color 150ms;
&:hover {
background-color: color-mix(in srgb, var(--mb-color-bg-light) 98%, #000);
}
&:has(.handle:focus) {
border-color: var(--mb-color-brand);
}
}
.scrollContainer {
position: absolute;
height: 100%;
width: 100%;
overflow: auto;
padding: 1rem;
cursor: text;
caret-color: auto;
}
.handle {
cursor: grab;
height: 1rem;
position: absolute;
top: 0;
width: 100%;
&:active {
cursor: grabbing;
}
}
.placeholder {
position: absolute;
pointer-events: none;
color: var(--mb-color-text-light);
}

View File

@@ -0,0 +1,128 @@
import {
Node,
type NodeViewProps,
findParentNode,
mergeAttributes,
} from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import { t } from "ttag";
import S from "./SupportingText.module.css";
export const SupportingText = Node.create<{
HTMLAttributes: Record<string, any>;
}>({
name: "supportingText",
group: "block",
content: "(paragraph|heading|bulletList|orderedList|blockquote|codeBlock)+",
draggable: false,
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="supportingText"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "supportingText",
}),
0,
];
},
addNodeView() {
return ReactNodeViewRenderer(SupportingTextComponent);
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { selection } = editor.state;
if (!selection.empty) {
return false;
}
const parentSupportingText = findParentNode(
(n) => n.type === this.type,
)(selection);
if (!parentSupportingText) {
return false;
}
// Remove this SupportingText block if the user hit Backspace and there was nothing in it
if (isNodeEmpty(parentSupportingText.node)) {
editor
.chain()
.setNodeSelection(parentSupportingText.pos)
.deleteSelection()
.focus(parentSupportingText.pos)
.run();
return true;
}
return false;
},
};
},
});
const isNodeEmpty = (node: ProseMirrorNode): boolean => {
const [firstChild] = node.content.content;
return (
node.content.content.length === 1 &&
firstChild?.type.name === "paragraph" &&
!firstChild.content.size
);
};
const SupportingTextComponent = ({
deleteNode,
editor,
getPos,
node,
}: NodeViewProps) => {
return (
<NodeViewWrapper className={S.wrapper}>
<div className={S.scrollContainer}>
{isNodeEmpty(node) && (
<div contentEditable={false} className={S.placeholder}>
{t`Write whatever you'd like to`}
</div>
)}
<NodeViewContent />
</div>
<button
contentEditable={false}
aria-label={t`Supporting text`}
className={S.handle}
onClick={(e) => {
e.currentTarget.focus();
}}
onKeyDown={(e) => {
if (e.key === "Backspace" || e.key === "Delete") {
const pos = getPos();
deleteNode();
if (pos != null) {
editor.commands.focus(pos);
}
}
}}
/>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,6 @@
import type { JSONContent } from "@tiptap/core";
export const wrapCardEmbed = (cardEmbedNode: JSONContent): JSONContent => ({
type: "resizeNode",
content: [cardEmbedNode],
});

View File

@@ -29,6 +29,7 @@ export const useCellMeasure = (
return (
<div
ref={rootRef}
data-element-id="data-grid-measure-root"
style={{
position: "absolute",
top: "-9999px",

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 16 16" fill="currentcolor" xmlns="http://www.w3.org/2000/svg">
<path d="M12.854 2.009A1.826 1.826 0 0 1 14.5 3.825v7.01a1.829 1.829 0 0 1-1.826 1.828H4.979a.51.51 0 0 0-.362.15L3.33 14.098a1.073 1.073 0 0 1-1.65-.162 1.072 1.072 0 0 1-.167-.438l-.013-.158V3.825A1.826 1.826 0 0 1 3.326 2h9.348l.18.009zM3.326 3.314a.512.512 0 0 0-.51.511v8.93l.872-.872.133-.121a1.826 1.826 0 0 1 1.158-.413h7.695a.513.513 0 0 0 .51-.513v-7.01a.512.512 0 0 0-.41-.5l-.1-.012H3.326zM8 4.921c.363 0 .657.294.657.657v1.096h1.096a.658.658 0 0 1 0 1.314H8.657v1.096a.657.657 0 1 1-1.314 0V7.988H6.247a.657.657 0 0 1 0-1.314h1.096V5.578c0-.363.294-.657.657-.657z" />
</svg>

After

Width:  |  Height:  |  Size: 673 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 16 16" fill="currentcolor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M10.667 11.917a.75.75 0 0 1 0 1.5H2a.75.75 0 0 1 0-1.5h8.667zM12 5.25a.75.75 0 0 1 .75.75v1.25H14a.75.75 0 0 1 0 1.5h-1.25V10a.75.75 0 0 1-1.5 0V8.75H10a.75.75 0 0 1 0-1.5h1.25V6a.75.75 0 0 1 .75-.75zm-5.25 2a.75.75 0 0 1 0 1.5H2a.75.75 0 0 1 0-1.5h4.75zm3.917-4.667a.75.75 0 0 1 0 1.5H2a.75.75 0 0 1 0-1.5h8.667z" fill="currentcolor"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 16 16" fill="currentcolor" xmlns="http://www.w3.org/2000/svg">
<path d="M13.185 3.765a.486.486 0 0 0-.15-.35.517.517 0 0 0-.26-.134l-.1-.01h-9.35a.52.52 0 0 0-.36.145.486.486 0 0 0-.15.349v8.632l.872-.843.134-.117c.325-.258.734-.4 1.157-.4h7.696a.52.52 0 0 0 .362-.145.487.487 0 0 0 .15-.35V3.765zm1.315 6.777c0 .469-.193.918-.535 1.249-.342.33-.806.517-1.29.517H4.977a.52.52 0 0 0-.36.145L3.33 13.696a1.107 1.107 0 0 1-1.168.225 1.067 1.067 0 0 1-.481-.382 1.016 1.016 0 0 1-.169-.423l-.012-.153V3.765c0-.468.193-.917.535-1.248.342-.33.806-.517 1.29-.517h9.35l.18.008c.418.04.81.22 1.11.51.342.33.535.779.535 1.247v6.777z" />
</svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@@ -6,10 +6,14 @@ import add_component from "./add.svg?component";
import add_source from "./add.svg?source";
import add_column_component from "./add_column.svg?component";
import add_column_source from "./add_column.svg?source";
import add_comment_component from "./add_comment.svg?component";
import add_comment_source from "./add_comment.svg?source";
import add_data_component from "./add_data.svg?component";
import add_data_source from "./add_data.svg?source";
import add_folder_component from "./add_folder.svg?component";
import add_folder_source from "./add_folder.svg?source";
import add_list_component from "./add_list.svg?component";
import add_list_source from "./add_list.svg?source";
import add_row_component from "./add_row.svg?component";
import add_row_source from "./add_row.svg?source";
import add_to_dash_component from "./add_to_dash.svg?component";
@@ -98,6 +102,8 @@ import code_block_component from "./code_block.svg?component";
import code_block_source from "./code_block.svg?source";
import combine_component from "./combine.svg?component";
import combine_source from "./combine.svg?source";
import comment_component from "./comment.svg?component";
import comment_source from "./comment.svg?source";
import company_component from "./company.svg?component";
import company_source from "./company.svg?source";
import compare_component from "./compare.svg?component";
@@ -260,8 +266,6 @@ import mail_component from "./mail.svg?component";
import mail_source from "./mail.svg?source";
import mail_filled_component from "./mail_filled.svg?component";
import mail_filled_source from "./mail_filled.svg?source";
import message_component from "./message.svg?component";
import message_source from "./message.svg?source";
import metabot_component from "./metabot.svg?component";
import metabot_source from "./metabot.svg?source";
import metabot_sad_component from "./metabot_sad.svg?component";
@@ -489,6 +493,10 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
component: add_folder_component,
source: add_folder_source,
},
add_list: {
component: add_list_component,
source: add_list_source,
},
add_row: {
component: add_row_component,
source: add_row_source,
@@ -497,6 +505,10 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
component: add_to_dash_component,
source: add_to_dash_source,
},
add_comment: {
component: add_comment_component,
source: add_comment_source,
},
ai: {
component: ai_component,
source: ai_source,
@@ -669,6 +681,10 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
component: company_component,
source: company_source,
},
comment: {
component: comment_component,
source: comment_source,
},
connections: {
component: connections_component,
source: connections_source,
@@ -989,10 +1005,6 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
component: mail_filled_component,
source: mail_filled_source,
},
message: {
component: message_component,
source: message_source,
},
metabot: {
component: metabot_component,
source: metabot_source,
@@ -1442,8 +1454,10 @@ export type IconName =
| "add_column"
| "add_data"
| "add_folder"
| "add_list"
| "add_row"
| "add_to_dash"
| "add_comment"
| "ai"
| "alert"
| "alert_filled"
@@ -1487,6 +1501,7 @@ export type IconName =
| "compare"
| "combine"
| "company"
| "comment"
| "currency"
| "connections"
| "contract"
@@ -1567,7 +1582,6 @@ export type IconName =
| "lock_filled"
| "mail"
| "mail_filled"
| "message"
| "metabot"
| "metabot_sad"
| "metric"

View File

@@ -1,3 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 427 B

View File

@@ -181,7 +181,9 @@ export default class FunnelNormal extends Component {
</Head>
<FunnelStart isNarrow={isNarrow}>
<Title>{formatMetric(sortedRows[0][metricIndex])}</Title>
<Subtitle>{cols[metricIndex].display_name}</Subtitle>
<Subtitle>
<Ellipsified>{cols[metricIndex].display_name}</Ellipsified>
</Subtitle>
</FunnelStart>
{/* This part of code in used only to share height between .Start and .Graph columns. */}
<Info isNarrow={isNarrow}>

View File

@@ -41,6 +41,7 @@
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-mention": "^3.3.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

@@ -0,0 +1,13 @@
diff --git a/node_modules/prosemirror-dropcursor/dist/index.js b/node_modules/prosemirror-dropcursor/dist/index.js
index c9bc7b5..ae9fcce 100644
--- a/node_modules/prosemirror-dropcursor/dist/index.js
+++ b/node_modules/prosemirror-dropcursor/dist/index.js
@@ -128,7 +128,7 @@ class DropCursorView {
target = point;
}
this.setCursor(target);
- this.scheduleRemoval(5000);
+ this.scheduleRemoval(1000);
}
}
dragend() {

View File

@@ -4156,6 +4156,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"