Compare commits

...

12 Commits

Author SHA1 Message Date
Denis Berezin
7fec8c902b temp 2025-09-09 00:14:41 +03:00
Denis Berezin
c56d25f8ec temp 2025-09-08 23:43:01 +03:00
Denis Berezin
6e7f6ceec9 Fix empty ResizeNode after card dnd 2025-09-08 17:22:14 +03:00
Denis Berezin
cd934a4d49 Fix FlexContainer to work with ResizeNode 2025-09-05 22:28:14 +03:00
Denis Berezin
4dfc9a0ec6 Merge branch 'master' into document-layout 2025-09-05 22:06:16 +03:00
Denis Berezin
c1fd1e8077 Merge branch 'uxw-638-ability-to-change-the-height-of-the-cards-even-if-not-side' into document-layout
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/documents/components/Editor/Editor.tsx
2025-09-05 22:06:03 +03:00
Nick Fitzpatrick
8a2eaa6c58 cleanup, add min height 2025-09-05 14:36:05 -03:00
Denis Berezin
a4c4ed1b2c Add Card node title ellipsification 2025-09-05 19:46:39 +03:00
Denis Berezin
4b75e74c3d Refactor to use grid container 2025-09-05 18:58:05 +03:00
Nick Fitzpatrick
b4ab64aa2b ResizeNode 2025-09-05 11:48:00 -03:00
Denis Berezin
32c7cbc728 Table layout and dnd polishing 2025-09-04 23:25:26 +03:00
Denis Berezin
d52c734799 Add working table with 2 columns 2025-09-04 18:55:45 +03:00
15 changed files with 1474 additions and 19 deletions

View File

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

View File

@@ -1,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}

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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();

View File

@@ -0,0 +1,78 @@
.flexContainer {
display: block;
width: 100%;
margin: 1rem 0;
border-radius: var(--mantine-radius-md);
position: relative;
max-width: 900px;
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);
}

View File

@@ -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";

View File

@@ -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;
}
}
}

View File

@@ -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>
);
};

View File

@@ -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" }),
},
},
},
];
},
});

View File

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

View File

@@ -0,0 +1,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,
};
};

View File

@@ -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,
};
};

View File

@@ -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": {

View File

@@ -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"