Compare commits
40 Commits
python-run
...
document-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
badfe8c57b | ||
|
|
f8cccfc871 | ||
|
|
22339543d1 | ||
|
|
abe9015fe2 | ||
|
|
1b2e0d51f9 | ||
|
|
3ef5e16451 | ||
|
|
c4af01a93f | ||
|
|
0d1de2d3ce | ||
|
|
717fdcf142 | ||
|
|
edc4282fef | ||
|
|
009f98cbfd | ||
|
|
fbcbc84826 | ||
|
|
754f87cb75 | ||
|
|
fbd7245254 | ||
|
|
fca680c03a | ||
|
|
e0f35fde53 | ||
|
|
aba3ff0d92 | ||
|
|
390921bf8e | ||
|
|
a26b7b6fcb | ||
|
|
632aeac3de | ||
|
|
7b8e2471fc | ||
|
|
020b538e08 | ||
|
|
173a7879ac | ||
|
|
d64aa8de7a | ||
|
|
f9e2301b69 | ||
|
|
b048a49649 | ||
|
|
a74271fd08 | ||
|
|
403bc655d5 | ||
|
|
8f5f19c9a2 | ||
|
|
df41cecf33 | ||
|
|
6e7f6ceec9 | ||
|
|
cd934a4d49 | ||
|
|
4dfc9a0ec6 | ||
|
|
c1fd1e8077 | ||
|
|
8a2eaa6c58 | ||
|
|
a4c4ed1b2c | ||
|
|
4b75e74c3d | ||
|
|
b4ab64aa2b | ||
|
|
32c7cbc728 | ||
|
|
d52c734799 |
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
|
||||
export const wrapCardEmbed = (cardEmbedNode: JSONContent): JSONContent => ({
|
||||
type: "resizeNode",
|
||||
content: [cardEmbedNode],
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export const useCellMeasure = (
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-element-id="data-grid-measure-root"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-9999px",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
patches/prosemirror-dropcursor+1.8.2.patch
Normal file
13
patches/prosemirror-dropcursor+1.8.2.patch
Normal 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() {
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user