Compare commits
12 Commits
python-run
...
document-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fec8c902b | ||
|
|
c56d25f8ec | ||
|
|
6e7f6ceec9 | ||
|
|
cd934a4d49 | ||
|
|
4dfc9a0ec6 | ||
|
|
c1fd1e8077 | ||
|
|
8a2eaa6c58 | ||
|
|
a4c4ed1b2c | ||
|
|
4b75e74c3d | ||
|
|
b4ab64aa2b | ||
|
|
32c7cbc728 | ||
|
|
d52c734799 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DragHandle from "@tiptap/extension-drag-handle-react";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
@@ -13,7 +14,7 @@ import { DND_IGNORE_CLASS_NAME } from "metabase/common/components/dnd";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { useSelector, useStore } from "metabase/lib/redux";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { Box, Loader } from "metabase/ui";
|
||||
import { Box, Icon, Loader } from "metabase/ui";
|
||||
import { getMentionsCache } from "metabase-enterprise/documents/selectors";
|
||||
import type { DocumentsStoreState } from "metabase-enterprise/documents/types";
|
||||
import { getMentionsCacheKey } from "metabase-enterprise/documents/utils/mentionsUtils";
|
||||
@@ -25,14 +26,22 @@ import { CommandExtension } from "./extensions/Command/CommandExtension";
|
||||
import { CommandSuggestion } from "./extensions/Command/CommandSuggestion";
|
||||
import { CustomStarterKit } from "./extensions/CustomStarterKit/CustomStarterKit";
|
||||
import { DisableMetabotSidebar } from "./extensions/DisableMetabotSidebar";
|
||||
import { FlexContainer } from "./extensions/FlexContainer/FlexContainer";
|
||||
import { MentionExtension } from "./extensions/Mention/MentionExtension";
|
||||
import { MentionSuggestion } from "./extensions/Mention/MentionSuggestion";
|
||||
import { MetabotNode, type PromptSerializer } from "./extensions/MetabotEmbed";
|
||||
import { MetabotMentionExtension } from "./extensions/MetabotMention/MetabotMentionExtension";
|
||||
import { MetabotMentionSuggestion } from "./extensions/MetabotMention/MetabotSuggestion";
|
||||
import { ResizeNode } from "./extensions/ResizeNode/ResizeNode";
|
||||
import { ResizeNodeDropCursor } from "./extensions/ResizeNodeDropCursor/ResizeNodeDropCursor";
|
||||
import { SmartLink } from "./extensions/SmartLink/SmartLinkNode";
|
||||
import { createSuggestionRenderer } from "./extensions/suggestionRenderer";
|
||||
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
|
||||
import {
|
||||
useCardEmbedDnD,
|
||||
useCardEmbedsTracking,
|
||||
useQuestionSelection,
|
||||
} from "./hooks";
|
||||
import { useResizeNodeDropCursor } from "./hooks/useResizeNodeDropCursor";
|
||||
import type { CardEmbedRef } from "./types";
|
||||
|
||||
const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
|
||||
@@ -40,6 +49,7 @@ const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
|
||||
MetabotNode.name,
|
||||
SmartLink.name,
|
||||
Image.name,
|
||||
FlexContainer.name,
|
||||
"codeBlock",
|
||||
];
|
||||
|
||||
@@ -117,6 +127,7 @@ export const Editor: React.FC<EditorProps> = ({
|
||||
placeholder: t`Start writing, press "/" to open command palette, or "@" to insert a link...`,
|
||||
}),
|
||||
CardEmbed,
|
||||
FlexContainer,
|
||||
MentionExtension.configure({
|
||||
suggestion: {
|
||||
allow: ({ state }) => !isMetabotBlock(state),
|
||||
@@ -139,6 +150,8 @@ export const Editor: React.FC<EditorProps> = ({
|
||||
render: createSuggestionRenderer(MetabotMentionSuggestion),
|
||||
},
|
||||
}),
|
||||
ResizeNode,
|
||||
ResizeNodeDropCursor,
|
||||
],
|
||||
[siteUrl, getState],
|
||||
);
|
||||
@@ -189,6 +202,9 @@ export const Editor: React.FC<EditorProps> = ({
|
||||
|
||||
useCardEmbedsTracking(editor, onCardEmbedsChange);
|
||||
useQuestionSelection(editor, onQuestionSelect);
|
||||
const { handleDragEnd } = useCardEmbedDnD(editor);
|
||||
const { handleElementDragStart, handleElementDragEnd } =
|
||||
useResizeNodeDropCursor(editor);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
@@ -235,6 +251,38 @@ export const Editor: React.FC<EditorProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DragHandle
|
||||
editor={editor}
|
||||
onElementDragStart={(e) => {
|
||||
console.log("onElementDragStart", e);
|
||||
|
||||
// Handle both CardEmbed and ResizeNode drag operations
|
||||
handleElementDragStart(e);
|
||||
}}
|
||||
onElementDragEnd={(e) => {
|
||||
console.log("onElementDragEnd", e);
|
||||
|
||||
// Handle both CardEmbed and ResizeNode drag operations
|
||||
handleDragEnd(e);
|
||||
handleElementDragEnd(e);
|
||||
}}
|
||||
// onNodeChange={(data) => {
|
||||
// console.log("onNodeChange", data);
|
||||
//
|
||||
// if (data.node != null) {
|
||||
// if (data.node.type.name !== "resizeNode") {
|
||||
// console.log("hideDragHandle true");
|
||||
// data.editor.state.tr.setMeta("hideDragHandle", true);
|
||||
// } else {
|
||||
// console.log("hideDragHandle false");
|
||||
// data.editor.state.tr.setMeta("hideDragHandle", false);
|
||||
// }
|
||||
// }
|
||||
// }}
|
||||
>
|
||||
<Icon name="grabber" size={16} color="var(--mb-color-text-medium)" />
|
||||
</DragHandle>
|
||||
|
||||
<EditorContent data-testid="document-content" editor={editor} />
|
||||
<EditorBubbleMenu
|
||||
editor={editor}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.embedWrapper {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cardEmbed {
|
||||
@@ -15,6 +16,7 @@
|
||||
transition: border-color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cardEmbed.selected {
|
||||
@@ -30,12 +32,12 @@
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.5rem 2rem;
|
||||
}
|
||||
|
||||
.questionResults {
|
||||
height: 400px;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
@@ -51,11 +53,19 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titleEditIcon {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleContainer:hover .titleEditIcon {
|
||||
@@ -66,3 +76,29 @@
|
||||
border: none;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
cursor: grab;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-color-bg-light);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.cardEmbed:hover .dragHandle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import cx from "classnames";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
|
||||
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
@@ -63,7 +64,6 @@ export const CardEmbed: Node<{
|
||||
name: "cardEmbed",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
@@ -274,7 +274,10 @@ export const CardEmbedComponent = memo(
|
||||
|
||||
if (isLoading && !card) {
|
||||
return (
|
||||
<NodeViewWrapper className={styles.embedWrapper}>
|
||||
<NodeViewWrapper
|
||||
className={styles.embedWrapper}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
[styles.selected]: selected,
|
||||
@@ -304,12 +307,22 @@ export const CardEmbedComponent = memo(
|
||||
<NodeViewWrapper
|
||||
className={styles.embedWrapper}
|
||||
data-testid="document-card-embed"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
>
|
||||
<Box className={styles.questionHeader}>
|
||||
<Flex align="center" justify="space-between" gap="0.5rem">
|
||||
<Box className={styles.titleContainer}>
|
||||
<Text size="md" color="text-dark" fw={700}>
|
||||
{t`Error loading question`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Flex className={styles.questionResults}>
|
||||
<ErrorView
|
||||
error={
|
||||
@@ -328,6 +341,7 @@ export const CardEmbedComponent = memo(
|
||||
<NodeViewWrapper
|
||||
className={styles.embedWrapper}
|
||||
data-testid="document-card-embed"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
@@ -366,15 +380,20 @@ export const CardEmbedComponent = memo(
|
||||
/>
|
||||
) : (
|
||||
<Box className={styles.titleContainer}>
|
||||
<Text
|
||||
size="md"
|
||||
color="text-dark"
|
||||
fw={700}
|
||||
onClick={handleTitleClick}
|
||||
c="pointer"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Ellipsified lines={1} tooltip={displayName}>
|
||||
<Text
|
||||
className={styles.titleText}
|
||||
size="md"
|
||||
color="text-dark"
|
||||
fw={700}
|
||||
c="pointer"
|
||||
truncate="end"
|
||||
onClick={handleTitleClick}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Ellipsified>
|
||||
|
||||
{canWrite && (
|
||||
<Icon
|
||||
name="pencil"
|
||||
|
||||
@@ -64,10 +64,15 @@ export const CommandExtension = Extension.create<CommandOptions>({
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: props.entityId,
|
||||
},
|
||||
type: "resizeNode",
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: props.entityId,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.setTextSelection(range.from + 1)
|
||||
.run();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.flexContainer {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
position: relative;
|
||||
max-width: 900px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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%;
|
||||
gap: 1rem;
|
||||
grid-template-columns: var(--mb-card-container-col-widths);
|
||||
}
|
||||
|
||||
.flexContent {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ensure CardEmbed nodes within flex container take appropriate space */
|
||||
.flexContent > :global(.react-renderer) {
|
||||
min-width: 0; /* Allow items to shrink below their natural width */
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: calc(50% - 2rem);
|
||||
width: 0.25rem;
|
||||
height: 4rem;
|
||||
margin-left: -0.125rem;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
border-radius: 100px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.flexContainer:hover .resizeHandle {
|
||||
opacity: 1;
|
||||
background-color: var(--mb-color-text-secondary-inverse);
|
||||
}
|
||||
|
||||
.flexContainer.resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.flexContainer.resizing .resizeHandle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Visual feedback for empty container */
|
||||
.flexContainer:empty::after {
|
||||
content: "Drop CardEmbed items here";
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--mb-color-text-light);
|
||||
font-style: italic;
|
||||
border: 2px dashed var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--mb-color-bg-light);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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: "cardEmbed{1,3}", // Contains 1-3 CardEmbed nodes only
|
||||
defining: true,
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
columnWidths: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const widths = element.getAttribute("data-column-widths");
|
||||
return widths ? JSON.parse(widths) : null;
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
return attributes.columnWidths
|
||||
? {
|
||||
"data-column-widths": JSON.stringify(attributes.columnWidths),
|
||||
}
|
||||
: {};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${FlexContainer.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": FlexContainer.name,
|
||||
class: "flex-container",
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
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 = 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);
|
||||
|
||||
// Account for grid gaps - each gap adds to the left offset
|
||||
// There are i gaps before this handle (gap after each of the first i columns)
|
||||
const gapCount = i;
|
||||
const gapOffset = gapCount * 0.5; // 1rem gap converted to percentage
|
||||
|
||||
handles.push(
|
||||
<div
|
||||
key={i}
|
||||
className={styles.resizeHandle}
|
||||
style={{
|
||||
left: `calc(${leftWidth}% + ${gapOffset}rem)`,
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
}}
|
||||
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={{
|
||||
"--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,48 @@
|
||||
.content {
|
||||
& > [data-node-view-content-react] {
|
||||
height: 100%;
|
||||
|
||||
& > :global(.node-cardEmbed) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& > :global(.node-flexContainer) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
height: 100%;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dragContainer {
|
||||
position: absolute;
|
||||
bottom: -0.375rem;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
background-color: var(--mb-color-border);
|
||||
height: 0.75rem;
|
||||
width: 10rem;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
border-radius: var(--default-border-radius);
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.dragContainer {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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 ResizeNode: Node<{
|
||||
HTMLAttributes: {
|
||||
height: number;
|
||||
minHeight: number;
|
||||
};
|
||||
}> = Node.create({
|
||||
name: "resizeNode",
|
||||
group: "block",
|
||||
content: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
height: {
|
||||
default: 442,
|
||||
parseHTML: (element) => element.getAttribute("data-height"),
|
||||
},
|
||||
minHeight: {
|
||||
default: 250,
|
||||
parseHTML: (element) => element.getAttribute("data-min-height"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
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,202 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
export interface ResizeNodeDropCursorOptions {
|
||||
/**
|
||||
* CSS class for the drop cursor
|
||||
* @default 'resize-node-drop-cursor'
|
||||
*/
|
||||
class: string;
|
||||
|
||||
/**
|
||||
* Width of the drop cursor line
|
||||
* @default '2px'
|
||||
*/
|
||||
width: string;
|
||||
|
||||
/**
|
||||
* Color of the drop cursor
|
||||
* @default 'var(--mb-color-brand)'
|
||||
*/
|
||||
color: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
resizeNodeDropCursor: {
|
||||
/**
|
||||
* Show drop cursor at position
|
||||
*/
|
||||
showResizeNodeDropCursor: (pos: number) => ReturnType;
|
||||
/**
|
||||
* Hide drop cursor
|
||||
*/
|
||||
hideResizeNodeDropCursor: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ResizeNodeDropCursor Extension
|
||||
*
|
||||
* Shows a visual drop cursor when dragging ResizeNode over other ResizeNodes.
|
||||
* Provides precise visual feedback for drop positioning.
|
||||
*/
|
||||
export const ResizeNodeDropCursor =
|
||||
Extension.create<ResizeNodeDropCursorOptions>({
|
||||
name: "resizeNodeDropCursor",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
class: "resize-node-drop-cursor",
|
||||
width: "6px",
|
||||
color: "var(--mb-color-brand)",
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
showResizeNodeDropCursor:
|
||||
(pos: number) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
tr.setMeta("resizeNodeDropCursor", { pos, show: true });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
hideResizeNodeDropCursor:
|
||||
() =>
|
||||
({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
tr.setMeta("resizeNodeDropCursor", { show: false });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("resizeNodeDropCursor"),
|
||||
|
||||
state: {
|
||||
init() {
|
||||
return {
|
||||
pos: null as number | null,
|
||||
show: false,
|
||||
};
|
||||
},
|
||||
|
||||
apply(tr, value) {
|
||||
const meta = tr.getMeta("resizeNodeDropCursor");
|
||||
if (meta) {
|
||||
return {
|
||||
pos: meta.pos ?? null,
|
||||
show: meta.show ?? false,
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
const { pos, show } = this.getState(state);
|
||||
|
||||
if (!show || pos === null) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const decoration = Decoration.widget(
|
||||
pos,
|
||||
() => {
|
||||
const cursor = document.createElement("div");
|
||||
cursor.className = this.spec.options.class;
|
||||
|
||||
// Apply inline styles for immediate visual feedback
|
||||
Object.assign(cursor.style, {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "-1px",
|
||||
width: this.spec.options.width,
|
||||
height: "100%",
|
||||
backgroundColor: this.spec.options.color,
|
||||
borderRadius: "1px",
|
||||
pointerEvents: "none",
|
||||
zIndex: "1000",
|
||||
boxShadow: `0 0 4px ${this.spec.options.color}`,
|
||||
transition: "opacity 0.15s ease-in-out",
|
||||
opacity: "1",
|
||||
});
|
||||
|
||||
return cursor;
|
||||
},
|
||||
{
|
||||
key: "resize-node-drop-cursor",
|
||||
side: 1, // Position after the node
|
||||
},
|
||||
);
|
||||
|
||||
return DecorationSet.create(state.doc, [decoration]);
|
||||
},
|
||||
},
|
||||
|
||||
// Helper methods for the DragHandle integration
|
||||
spec: {
|
||||
options: this.options,
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
// Helper function to find ResizeNode at coordinates (for DragHandle integration)
|
||||
addStorage() {
|
||||
return {
|
||||
findResizeNodeAtCoords: (
|
||||
view: any,
|
||||
coords: { x: number; y: number },
|
||||
) => {
|
||||
const pos = view.posAtCoords({
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
});
|
||||
|
||||
if (!pos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const $pos = view.state.doc.resolve(pos.pos);
|
||||
|
||||
// Walk up the node tree to find a ResizeNode
|
||||
for (let depth = $pos.depth; depth >= 0; depth--) {
|
||||
const node = $pos.node(depth);
|
||||
if (node.type.name === "resizeNode") {
|
||||
return {
|
||||
node,
|
||||
pos: $pos.start(depth) - 1, // Position before the node
|
||||
endPos: $pos.start(depth) - 1 + node.nodeSize, // Position after the node
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// Add global attributes for styling
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ["resizeNode"],
|
||||
attributes: {
|
||||
"data-resize-node-droppable": {
|
||||
default: "true",
|
||||
renderHTML: () => ({ "data-resize-node-droppable": "true" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useCardEmbedSelection as useQuestionSelection } from "./useCardEmbedSelection";
|
||||
export { useCardEmbedsTracking } from "./useCardEmbedsTracking";
|
||||
export { useCardEmbedDnD } from "./useCardEmbedDnD";
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
const cleanupEmptyResizeNodes = (transaction: any) => {
|
||||
const emptyResizeNodePositions: { pos: number; node: any }[] = [];
|
||||
|
||||
// Find all ResizeNode instances in the document
|
||||
transaction.doc.descendants((node: any, pos: number) => {
|
||||
if (node.type.name === "resizeNode") {
|
||||
// Check if the ResizeNode is empty or contains only whitespace
|
||||
const hasContent =
|
||||
node.content.content?.some((child: any) => {
|
||||
if (child.type.name === "paragraph") {
|
||||
// Check if paragraph has actual text content (not just whitespace)
|
||||
return child.textContent?.trim().length > 0;
|
||||
}
|
||||
// Non-paragraph content (like CardEmbed) is considered meaningful
|
||||
return (
|
||||
child.type.name !== "paragraph" ||
|
||||
child.textContent?.trim().length > 0
|
||||
);
|
||||
}) || false;
|
||||
|
||||
if (!hasContent) {
|
||||
emptyResizeNodePositions.push({ pos, node });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove empty ResizeNodes in reverse order to maintain positions
|
||||
emptyResizeNodePositions
|
||||
.sort((a, b) => b.pos - a.pos)
|
||||
.forEach(({ pos, node }) => {
|
||||
transaction.delete(pos, pos + node.nodeSize);
|
||||
});
|
||||
|
||||
return transaction;
|
||||
};
|
||||
|
||||
export const useCardEmbedDnD = (editor: TiptapEditor | null) => {
|
||||
/**
|
||||
* Finds and removes empty ResizeNode containers after a CardEmbed has been moved.
|
||||
* A ResizeNode is considered empty if it contains no content or only whitespace.
|
||||
*/
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle reordering of CardEmbed nodes
|
||||
if (editor && active.id.toString().startsWith("card-embed-")) {
|
||||
const sourceId = active.id.toString().replace("card-embed-", "");
|
||||
const targetZoneId = over.id.toString().replace("drop-zone-", "");
|
||||
const direction = over.id.toString().includes("-left")
|
||||
? "left"
|
||||
: "right";
|
||||
const targetId = targetZoneId
|
||||
.replace("-left", "")
|
||||
.replace("-right", "");
|
||||
|
||||
// Don't allow dropping next to itself
|
||||
if (sourceId === targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the source and target nodes in the document
|
||||
let sourcePos = -1;
|
||||
let targetPos = -1;
|
||||
let sourceNode: any = null;
|
||||
let targetNode: any = null;
|
||||
let targetFlexContainerPos = -1;
|
||||
let targetFlexContainer: any = null;
|
||||
let sourceFlexContainerPos = -1;
|
||||
let sourceFlexContainer: any = null;
|
||||
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node.type.name === "cardEmbed" &&
|
||||
node.attrs.id?.toString() === sourceId
|
||||
) {
|
||||
sourcePos = pos;
|
||||
sourceNode = node;
|
||||
|
||||
// Check if the source CardEmbed is inside a FlexContainer
|
||||
const currentPos = pos;
|
||||
editor.state.doc.nodesBetween(
|
||||
0,
|
||||
editor.state.doc.content.size,
|
||||
(node, nodePos) => {
|
||||
if (
|
||||
nodePos <= currentPos &&
|
||||
currentPos < nodePos + node.nodeSize
|
||||
) {
|
||||
if (node.type.name === "flexContainer") {
|
||||
sourceFlexContainerPos = nodePos;
|
||||
sourceFlexContainer = node;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
node.type.name === "cardEmbed" &&
|
||||
node.attrs.id?.toString() === targetId
|
||||
) {
|
||||
targetPos = pos;
|
||||
targetNode = node;
|
||||
|
||||
// Check if this CardEmbed is inside a FlexContainer by walking up the tree
|
||||
const currentPos = pos;
|
||||
editor.state.doc.nodesBetween(
|
||||
0,
|
||||
editor.state.doc.content.size,
|
||||
(node, nodePos) => {
|
||||
if (
|
||||
nodePos <= currentPos &&
|
||||
currentPos < nodePos + node.nodeSize
|
||||
) {
|
||||
if (node.type.name === "flexContainer") {
|
||||
targetFlexContainerPos = nodePos;
|
||||
targetFlexContainer = node;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (sourcePos !== -1 && targetPos !== -1 && sourceNode && targetNode) {
|
||||
const tr = editor.state.tr;
|
||||
|
||||
// Check if both source and target are in the same FlexContainer
|
||||
const bothInSameFlexContainer =
|
||||
sourceFlexContainerPos !== -1 &&
|
||||
targetFlexContainerPos !== -1 &&
|
||||
sourceFlexContainerPos === targetFlexContainerPos;
|
||||
|
||||
if (bothInSameFlexContainer) {
|
||||
// Swap positions within the same FlexContainer
|
||||
const flexContainer = sourceFlexContainer;
|
||||
const containerContent = flexContainer.content.content
|
||||
? flexContainer.content.content
|
||||
: flexContainer.content;
|
||||
|
||||
// Find the indices for source and target CardEmbeds
|
||||
let sourceIndex = -1;
|
||||
let targetIndex = -1;
|
||||
let childPos = sourceFlexContainerPos + 1; // Start after container opening tag
|
||||
|
||||
for (let i = 0; i < containerContent.length; i++) {
|
||||
const child = containerContent[i];
|
||||
if (
|
||||
childPos <= sourcePos &&
|
||||
sourcePos < childPos + child.nodeSize
|
||||
) {
|
||||
sourceIndex = i;
|
||||
}
|
||||
if (
|
||||
childPos <= targetPos &&
|
||||
targetPos < childPos + child.nodeSize
|
||||
) {
|
||||
targetIndex = i;
|
||||
}
|
||||
childPos += child.nodeSize;
|
||||
}
|
||||
|
||||
if (sourceIndex !== -1 && targetIndex !== -1) {
|
||||
// Create new container content with reordered CardEmbeds
|
||||
const newContainerContent = [...containerContent];
|
||||
|
||||
if (direction === "left") {
|
||||
// Insert source before target
|
||||
const sourceChild = newContainerContent[sourceIndex];
|
||||
newContainerContent.splice(sourceIndex, 1); // Remove source
|
||||
|
||||
// Adjust target index if source was before it
|
||||
const adjustedTargetIndex =
|
||||
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
||||
newContainerContent.splice(adjustedTargetIndex, 0, sourceChild);
|
||||
} else {
|
||||
// Insert source after target
|
||||
const sourceChild = newContainerContent[sourceIndex];
|
||||
newContainerContent.splice(sourceIndex, 1); // Remove source
|
||||
|
||||
// Adjust target index if source was before it
|
||||
const adjustedTargetIndex =
|
||||
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
||||
newContainerContent.splice(
|
||||
adjustedTargetIndex + 1,
|
||||
0,
|
||||
sourceChild,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the updated FlexContainer
|
||||
const newFlexContainer = {
|
||||
type: "flexContainer",
|
||||
attrs: flexContainer.attrs,
|
||||
content: newContainerContent.map((child) =>
|
||||
child.toJSON ? child.toJSON() : child,
|
||||
),
|
||||
};
|
||||
|
||||
// Replace the entire FlexContainer
|
||||
tr.replaceWith(
|
||||
sourceFlexContainerPos,
|
||||
sourceFlexContainerPos + flexContainer.nodeSize,
|
||||
editor.schema.nodeFromJSON(newFlexContainer),
|
||||
);
|
||||
}
|
||||
} else if (targetFlexContainerPos !== -1 && targetFlexContainer) {
|
||||
// Target is already in a FlexContainer - add the source CardEmbed to it
|
||||
const containerContent = targetFlexContainer.content.content
|
||||
? targetFlexContainer.content.content
|
||||
: targetFlexContainer.content;
|
||||
|
||||
// Check if FlexContainer already has 3 CardEmbeds (maximum allowed)
|
||||
if (containerContent.length >= 3) {
|
||||
return; // Don't allow more than 3 CardEmbeds
|
||||
}
|
||||
|
||||
const newContainerContent = [...containerContent];
|
||||
|
||||
// Insert the source CardEmbed at the appropriate position based on direction
|
||||
if (direction === "left") {
|
||||
// Find the index of the target CardEmbed and insert before it
|
||||
let targetIndex = 0;
|
||||
let childPos = targetFlexContainerPos + 1; // Start after container opening tag
|
||||
|
||||
for (let i = 0; i < containerContent.length; i++) {
|
||||
const child = containerContent[i];
|
||||
if (
|
||||
childPos <= targetPos &&
|
||||
targetPos < childPos + child.nodeSize
|
||||
) {
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
childPos += child.nodeSize;
|
||||
}
|
||||
|
||||
newContainerContent.splice(targetIndex, 0, sourceNode);
|
||||
} else {
|
||||
// Insert after the target CardEmbed
|
||||
let targetIndex = 0;
|
||||
let childPos = targetFlexContainerPos + 1;
|
||||
|
||||
for (let i = 0; i < containerContent.length; i++) {
|
||||
const child = containerContent[i];
|
||||
if (
|
||||
childPos <= targetPos &&
|
||||
targetPos < childPos + child.nodeSize
|
||||
) {
|
||||
targetIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
childPos += child.nodeSize;
|
||||
}
|
||||
|
||||
newContainerContent.splice(targetIndex, 0, sourceNode);
|
||||
}
|
||||
|
||||
// Create the new FlexContainer with updated content
|
||||
const newFlexContainer = {
|
||||
type: "flexContainer",
|
||||
attrs: targetFlexContainer.attrs,
|
||||
content: newContainerContent.map((child) =>
|
||||
child.toJSON ? child.toJSON() : child,
|
||||
),
|
||||
};
|
||||
|
||||
// Replace the FlexContainer and remove the source node
|
||||
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
|
||||
|
||||
// Adjust container position if source was before it
|
||||
const adjustedContainerPos =
|
||||
sourcePos < targetFlexContainerPos
|
||||
? targetFlexContainerPos - sourceNode.nodeSize
|
||||
: targetFlexContainerPos;
|
||||
tr.replaceWith(
|
||||
adjustedContainerPos,
|
||||
adjustedContainerPos + targetFlexContainer.nodeSize,
|
||||
editor.schema.nodeFromJSON(newFlexContainer),
|
||||
);
|
||||
} else {
|
||||
// Target is not in a FlexContainer - create a new FlexContainer with both CardEmbeds
|
||||
const leftCardEmbed =
|
||||
direction === "left" ? sourceNode.toJSON() : targetNode.toJSON();
|
||||
const rightCardEmbed =
|
||||
direction === "left" ? targetNode.toJSON() : sourceNode.toJSON();
|
||||
|
||||
const flexContainerContent = {
|
||||
type: "flexContainer",
|
||||
attrs: {},
|
||||
content: [leftCardEmbed, rightCardEmbed],
|
||||
};
|
||||
|
||||
// Delete nodes in reverse order to maintain positions
|
||||
if (sourcePos > targetPos) {
|
||||
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
|
||||
tr.delete(targetPos, targetPos + targetNode.nodeSize);
|
||||
} else {
|
||||
tr.delete(targetPos, targetPos + targetNode.nodeSize);
|
||||
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
|
||||
}
|
||||
|
||||
// Insert the FlexContainer at the position of whichever node was first
|
||||
const insertPos = Math.min(sourcePos, targetPos);
|
||||
tr.insert(
|
||||
insertPos,
|
||||
editor.schema.nodeFromJSON(flexContainerContent),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply cleanup logic to remove any empty ResizeNodes
|
||||
const cleanedTransaction = cleanupEmptyResizeNodes(tr);
|
||||
|
||||
editor.view.dispatch(cleanedTransaction);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return {
|
||||
handleDragEnd,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook to manage ResizeNode drop cursor functionality
|
||||
* Integrates with the existing DragHandle extension to provide visual feedback
|
||||
*/
|
||||
export const useResizeNodeDropCursor = (editor: TiptapEditor | null) => {
|
||||
const dragStateRef = useRef<{
|
||||
isDragging: boolean;
|
||||
draggedNodeType: string | null;
|
||||
}>({
|
||||
isDragging: false,
|
||||
draggedNodeType: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle drag start - initialize drop cursor tracking
|
||||
*/
|
||||
const handleElementDragStart = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the dragged node from the editor's current selection
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
|
||||
// Find the node being dragged by walking up from the selection
|
||||
let draggedNode = null;
|
||||
for (let depth = $from.depth; depth >= 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "resizeNode") {
|
||||
draggedNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("handleElementDragStart", { draggedNode });
|
||||
|
||||
if (draggedNode?.type.name === "resizeNode") {
|
||||
dragStateRef.current = {
|
||||
isDragging: true,
|
||||
draggedNodeType: "resizeNode",
|
||||
};
|
||||
|
||||
console.log("handleElementDragStart --- setRef");
|
||||
|
||||
// Add a class to the body to indicate ResizeNode dragging
|
||||
document.body.classList.add("resize-node-dragging");
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle drag end - cleanup drop cursor state
|
||||
*/
|
||||
const handleElementDragEnd = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always hide the drop cursor and reset state
|
||||
editor.commands.hideResizeNodeDropCursor();
|
||||
|
||||
dragStateRef.current = {
|
||||
isDragging: false,
|
||||
draggedNodeType: null,
|
||||
};
|
||||
|
||||
// Remove dragging class
|
||||
document.body.classList.remove("resize-node-dragging");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse move during drag - show drop cursor at appropriate positions
|
||||
*/
|
||||
const handleDragMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
console.log("handleDragMove --- useCallback");
|
||||
|
||||
if (
|
||||
!editor ||
|
||||
!dragStateRef.current.isDragging ||
|
||||
dragStateRef.current.draggedNodeType !== "resizeNode"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the extension's storage function to find ResizeNode at coordinates
|
||||
const resizeNodeDropCursorExtension =
|
||||
editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "resizeNodeDropCursor",
|
||||
);
|
||||
|
||||
if (!resizeNodeDropCursorExtension?.storage?.findResizeNodeAtCoords) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetResizeNode =
|
||||
resizeNodeDropCursorExtension.storage.findResizeNodeAtCoords(
|
||||
editor.view,
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
);
|
||||
|
||||
console.log("handleDragMove ---", { targetResizeNode });
|
||||
|
||||
if (targetResizeNode) {
|
||||
// Show drop cursor to the right of the target ResizeNode
|
||||
editor.commands.showResizeNodeDropCursor(targetResizeNode.endPos);
|
||||
} else {
|
||||
// Hide drop cursor if not over a ResizeNode
|
||||
editor.commands.hideResizeNodeDropCursor();
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
/**
|
||||
* Enhanced drag start handler that also sets up mouse move listener
|
||||
*/
|
||||
const enhancedDragStart = useCallback(
|
||||
(event: DragEvent) => {
|
||||
handleElementDragStart(event);
|
||||
|
||||
// Add mouse move listener for drop cursor positioning
|
||||
if (dragStateRef.current.isDragging) {
|
||||
console.log("enhancedDragStart --- isDragging");
|
||||
|
||||
document.addEventListener("mousemove", handleDragMove);
|
||||
}
|
||||
},
|
||||
[handleElementDragStart, handleDragMove],
|
||||
);
|
||||
|
||||
/**
|
||||
* Enhanced drag end handler that also cleans up mouse move listener
|
||||
*/
|
||||
const enhancedDragEnd = useCallback(
|
||||
(event: DragEvent) => {
|
||||
// Remove mouse move listener
|
||||
document.removeEventListener("mousemove", handleDragMove);
|
||||
|
||||
handleElementDragEnd(event);
|
||||
},
|
||||
[handleElementDragEnd, handleDragMove],
|
||||
);
|
||||
|
||||
return {
|
||||
handleElementDragStart: enhancedDragStart,
|
||||
handleElementDragEnd: enhancedDragEnd,
|
||||
};
|
||||
};
|
||||
@@ -38,12 +38,18 @@
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@tiptap/core": "^3.2.0",
|
||||
"@tiptap/extension-bubble-menu": "^3.2.0",
|
||||
"@tiptap/extension-collaboration": "^3.4.1",
|
||||
"@tiptap/extension-drag-handle": "^3.4.1",
|
||||
"@tiptap/extension-drag-handle-react": "^3.4.1",
|
||||
"@tiptap/extension-image": "^3.2.0",
|
||||
"@tiptap/extension-node-range": "^3.4.1",
|
||||
"@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",
|
||||
"@tiptap/suggestion": "^3.2.0",
|
||||
"@tiptap/y-tiptap": "^3.0.0",
|
||||
"@types/react-router": "3.0.27",
|
||||
"@types/redux-auth-wrapper": "^2.0.16",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
@@ -147,6 +153,8 @@
|
||||
"ttag": "1.7.21",
|
||||
"typescript": "^5.4.5",
|
||||
"underscore": "~1.13.3",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.27",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
80
yarn.lock
80
yarn.lock
@@ -1908,6 +1908,13 @@
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
|
||||
"@floating-ui/core@^1.7.3":
|
||||
version "1.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7"
|
||||
integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.10"
|
||||
|
||||
"@floating-ui/dom@^1.0.0":
|
||||
version "1.6.12"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
|
||||
@@ -1916,6 +1923,14 @@
|
||||
"@floating-ui/core" "^1.6.0"
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
|
||||
"@floating-ui/dom@^1.6.13":
|
||||
version "1.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.4.tgz#ee667549998745c9c3e3e84683b909c31d6c9a77"
|
||||
integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.7.3"
|
||||
"@floating-ui/utils" "^0.2.10"
|
||||
|
||||
"@floating-ui/react-dom@^2.1.2":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31"
|
||||
@@ -1932,6 +1947,11 @@
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
tabbable "^6.0.0"
|
||||
|
||||
"@floating-ui/utils@^0.2.10":
|
||||
version "0.2.10"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
|
||||
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
|
||||
|
||||
"@floating-ui/utils@^0.2.8":
|
||||
version "0.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
|
||||
@@ -4077,11 +4097,28 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.2.0.tgz#61927a22065d9ec20f590daab8bd9ac23baff638"
|
||||
integrity sha512-8eUeWzT0YNxvT9yh6p9mRziUmZjiRqHeQhHbcRYDM7gdFKb0mgsWPxiaonns0sdj7WW1vP6g0M80PC+HYCIO+g==
|
||||
|
||||
"@tiptap/extension-collaboration@^3.4.1":
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-3.4.1.tgz#49796fe60ccb49d8a082903b950b8c6f578648f1"
|
||||
integrity sha512-3JSVgR4PYdcS/IoRSYH+4XZb+6g9rzEmxqQQn/pkb89rsMz/u13tMjWGqLLaluSnAJhdcVnwKeaDCWzPhEYnjw==
|
||||
|
||||
"@tiptap/extension-document@^3.2.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.2.0.tgz#94452997aec6d569bc8b37872bce7e9fbc87e5fc"
|
||||
integrity sha512-2SQoew2HtOwuORpk8l7NjKDzsCBotMsij9vRSTp4nwoa4ZFGwmlVMsNIaKq/E/GuS7oiejmUUROsiD3w1stPKw==
|
||||
|
||||
"@tiptap/extension-drag-handle-react@^3.4.1":
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-drag-handle-react/-/extension-drag-handle-react-3.4.1.tgz#c224888b090c35b74f92c54ba771e6cf9c54c210"
|
||||
integrity sha512-r41mP5RteL/RPQRh59/Iq57Z1rgVVtgi+mE3L634nPi6wku07QO/EyT9eU6kughwyOlClugtGtO9a7KADv5maQ==
|
||||
|
||||
"@tiptap/extension-drag-handle@^3.4.1":
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.1.tgz#843202fa6fc4fc6eeb60d272cd6f9d0127237fc7"
|
||||
integrity sha512-pnn8AUdTbdwbjHjTlZqZT2hjv92EZuIhVvENiHi+3cITP+OAaZ9OaH+QkBS2ecjv5c1IThC5WrpyK4r9VX0Uew==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.6.13"
|
||||
|
||||
"@tiptap/extension-dropcursor@^3.2.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.2.0.tgz#a890e596d507ececa04583494b4674bc4b53584f"
|
||||
@@ -4144,6 +4181,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.2.0.tgz#cd9251891120a0213f69edfb95fa3b2fbd1aa494"
|
||||
integrity sha512-/3ZL9MpgDnEOv8tPwFwW3ct/TBbxZg3r3rN9noLHOR+YgiZKwbpMt9pDSJGu8y++rzT3vIHVmRRp0xFOXBvptQ==
|
||||
|
||||
"@tiptap/extension-node-range@^3.4.1":
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-node-range/-/extension-node-range-3.4.1.tgz#ad5d7aae316db7a59886ee1734bc7042a5ec50b5"
|
||||
integrity sha512-EeBk8c9uVW4xLjNJwaTFb1nDkO/qxDRK/BEb+XIFz78oIN0KBFBY99FhUUpinu3p1fszjrsZGkkO0CSBuRXPcA==
|
||||
|
||||
"@tiptap/extension-ordered-list@^3.2.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.2.0.tgz#9d8e466154f42b27fa71afc4561cf73f38c4c930"
|
||||
@@ -4164,6 +4206,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"
|
||||
@@ -4250,6 +4297,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-3.2.0.tgz#414dbd028539054a0ebf9d910b30018a50c783c3"
|
||||
integrity sha512-AEkZPsn9Fn3p/3e6LIz8dyF3npU0JjVa2rCGqj/MmmvX9MD2fRYhiTCT1y7q9Q9/4wB4+CiOT7Mt4sWiijEkxw==
|
||||
|
||||
"@tiptap/y-tiptap@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/y-tiptap/-/y-tiptap-3.0.0.tgz#e2cf46b9346dd4666f0ecceab45bb4bccf85f1ce"
|
||||
integrity sha512-HIeJZCj+KYJde2x6fONzo4o6kd7gW7eonwhQsv2p2VQnUgwNXMVhN+D6Z3AH/2i541Sq33y1PO4U/1ThCPjqbA==
|
||||
dependencies:
|
||||
lib0 "^0.2.100"
|
||||
|
||||
"@tokenizer/token@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
|
||||
@@ -13348,6 +13402,11 @@ isobject@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0"
|
||||
integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==
|
||||
|
||||
isomorphic.js@^0.2.4:
|
||||
version "0.2.5"
|
||||
resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88"
|
||||
integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==
|
||||
|
||||
isstream@~0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
|
||||
@@ -14296,6 +14355,13 @@ levn@^0.4.1:
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
lib0@^0.2.100, lib0@^0.2.85, lib0@^0.2.99:
|
||||
version "0.2.114"
|
||||
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.114.tgz#0b0e55c3ffa8768fe3d9efca971059f465db4baf"
|
||||
integrity sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==
|
||||
dependencies:
|
||||
isomorphic.js "^0.2.4"
|
||||
|
||||
lighthouse-logger@^1.0.0:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa"
|
||||
@@ -22427,6 +22493,13 @@ xxhashjs@~0.2.2:
|
||||
dependencies:
|
||||
cuint "^0.2.2"
|
||||
|
||||
y-protocols@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.6.tgz#66dad8a95752623443e8e28c0e923682d2c0d495"
|
||||
integrity sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==
|
||||
dependencies:
|
||||
lib0 "^0.2.85"
|
||||
|
||||
y18n@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4"
|
||||
@@ -22602,6 +22675,13 @@ yauzl@^2.10.0:
|
||||
buffer-crc32 "~0.2.3"
|
||||
fd-slicer "~1.1.0"
|
||||
|
||||
yjs@^13.6.27:
|
||||
version "13.6.27"
|
||||
resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.27.tgz#8899be929d57da05a0aa112d044a5c204393ab7b"
|
||||
integrity sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==
|
||||
dependencies:
|
||||
lib0 "^0.2.99"
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
|
||||
Reference in New Issue
Block a user