Skip to content
2 changes: 1 addition & 1 deletion apps/backend/src/agents/tools/story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default createTool<story.Input, story.Output>({
'or "replace" to overwrite the entire content (producing a new version).',
'Charts are embedded via <chart query_id="..." chart_type="..." x_axis_key="..." series=\'[...]\' title="..." />.',
'SQL result tables are embedded via <table query_id="..." title="..." />.',
'Use <grid widths="w1,w2,...">...</grid> to place 2–4 charts/tables side by side; its direct <chart>/<table> blocks are the columns.',
'Use <grid>...</grid> to place 2–4 charts/tables side by side; its direct <chart>/<table> blocks are the columns.',
'For unequal columns add widths="w1,w2,..." to the <grid> — one positive integer per column giving its relative width (e.g. widths="2,1" makes the first column twice as wide as the second). The number of values must equal the number of columns; omit widths for equal columns. Choose widths that fit the content, e.g. a wide time-series next to a narrow KPI or pie.',
'Use consecutive <tab title="...">...</tab> blocks to organize a story into top-level tabs.',
'Default to a single flowing story. Use tabs only when the user asks for tabs, or when the content splits into clearly distinct sections that are better separated than stacked (e.g. overview vs. detail, one topic/department/metric per tab). Avoid tabs for a short or single-topic story. Always follow the user\'s explicit request (e.g. "a tab per chart" means one chart per tab). When using tabs, the entire story must consist of <tab title="...">...</tab> blocks — no content outside a tab.',
Expand Down
147 changes: 144 additions & 3 deletions apps/frontend/src/components/side-panel/hooks/use-story-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ import { Fragment, Slice } from '@tiptap/pm/model';
import { dropPoint } from '@tiptap/pm/transform';
import { useEditor } from '@tiptap/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
blockSelectionPluginKey,
buildBlockMoveTransaction,
getSelectedBlockPositions,
} from '../story-block-selection';
import { EDITOR_EXTENSIONS } from '../story-editor-extensions';
import { GRID_COLUMN_DRAG_TYPE, STORY_BLOCK_DRAG_TYPE } from '../story-editor-drag-context';
import { createBlockNode, preprocessForEditor, removeCardFromOrigin } from '../story-editor-utils';
import {
cloneElementWithStyles,
createBlockNode,
dispatchDropWithScroll,
preprocessForEditor,
removeCardFromOrigin,
} from '../story-editor-utils';
import type { GridDragSource, StoryBlockDragSource } from '../story-editor-drag-context';
import type { Node as PMNode } from '@tiptap/pm/model';
import type { Editor } from '@tiptap/react';
Expand All @@ -28,6 +39,10 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams
const onSaveRef = useRef(onSave);
const gridDragSourceRef = useRef<GridDragSource | null>(null);
const storyBlockSourceRef = useRef<StoryBlockDragSource | null>(null);
const multiBlockDragRef = useRef<number[] | null>(null);
const handleNodePosRef = useRef<number | null>(null);
const dragPreviewPositionsRef = useRef<number[] | null>(null);
const storyEditorRef = useRef<HTMLDivElement>(null);
const [isBlockDragging, setIsBlockDragging] = useState(false);
const [handleNodeType, setHandleNodeType] = useState<string | null>(null);
const storyBlockDragContext = useMemo(
Expand All @@ -41,10 +56,12 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams
const resetDragContexts = useCallback(() => {
gridDragSourceRef.current = null;
storyBlockSourceRef.current = null;
multiBlockDragRef.current = null;
setIsBlockDragging(false);
}, []);
const handleDragHandleNodeChange = useCallback(({ node }: { node: PMNode | null }) => {
const handleDragHandleNodeChange = useCallback(({ node, pos }: { node: PMNode | null; pos: number }) => {
setHandleNodeType(node?.type.name ?? null);
handleNodePosRef.current = node ? pos : null;
}, []);
onSaveRef.current = onSave;

Expand Down Expand Up @@ -84,6 +101,34 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams
},
handleDrop(view, event) {
const dataTransfer = event.dataTransfer;
if (multiBlockDragRef.current && multiBlockDragRef.current.length > 1) {
try {
const positions = multiBlockDragRef.current;
const { state } = view;
const nodes = positions
.map((position) => state.doc.nodeAt(position))
.filter((node): node is PMNode => node != null);
if (nodes.length === 0) {
return true;
}
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!coords) {
return true;
}
const slice = new Slice(Fragment.fromArray(nodes), 0, 0);
const insertPos = dropPoint(state.doc, coords.pos, slice) ?? coords.pos;
const move = buildBlockMoveTransaction(state, positions, insertPos);
if (!move) {
return true;
}
dispatchDropWithScroll(view, move.transaction, move.insertPos);
event.preventDefault();
return true;
} finally {
resetDragContexts();
}
}

if (dataTransfer?.types.includes(GRID_COLUMN_DRAG_TYPE)) {
try {
const source = gridDragSourceRef.current;
Expand Down Expand Up @@ -185,6 +230,61 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams
},
});

useEffect(() => {
const container = storyEditorRef.current;
if (!container || !editor) {
return;
}

const onDragStart = (event: DragEvent) => {
const positions = dragPreviewPositionsRef.current;
if (!positions || positions.length === 0 || !event.dataTransfer) {
return;
}

const nodes = positions
.map((position) => editor.view.nodeDOM(position))
.filter((dom): dom is HTMLElement => dom instanceof HTMLElement);
if (nodes.length === 0) {
return;
}

const preview = document.createElement('div');
preview.style.position = 'absolute';
preview.style.top = '-10000px';
preview.style.left = '-10000px';

for (const dom of nodes) {
preview.appendChild(cloneElementWithStyles(dom));
}

document.body.appendChild(preview);
event.dataTransfer.setDragImage(preview, 16, 16);

const cleanup = () => {
preview.remove();
document.removeEventListener('dragend', cleanup);
document.removeEventListener('drop', cleanup);
};
document.addEventListener('dragend', cleanup);
document.addEventListener('drop', cleanup);
};

const clearDropCursor = () => {
dragPreviewPositionsRef.current = null;
editor.view.dom.dispatchEvent(new DragEvent('dragleave'));
};

container.addEventListener('dragstart', onDragStart);
document.addEventListener('dragend', clearDropCursor, true);
document.addEventListener('drop', clearDropCursor, true);
return () => {
container.removeEventListener('dragstart', onDragStart);
document.removeEventListener('dragend', clearDropCursor, true);
document.removeEventListener('drop', clearDropCursor, true);
};
}, [editor]);

useEffect(() => {
editorRef.current = editor;
return () => {
Expand All @@ -202,5 +302,46 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams
editor.commands.setContent(processedContent, { emitUpdate: false, contentType: 'markdown' });
}, [editor, code, processedContent]);

return { editor, gridDragSourceRef, storyBlockDragContext, handleDragHandleNodeChange, handleNodeType };
const onElementDragStart = useCallback(
(event: DragEvent) => {
if (!editor) {
return;
}
const selected = getSelectedBlockPositions(editor.state);
const hoveredPosition = handleNodePosRef.current;
const isMulti = selected.length > 1 && hoveredPosition != null && selected.includes(hoveredPosition);
if (isMulti) {
const sorted = [...selected].sort((first, second) => first - second);
multiBlockDragRef.current = sorted;
dragPreviewPositionsRef.current = sorted;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
} else {
multiBlockDragRef.current = null;
dragPreviewPositionsRef.current = hoveredPosition != null ? [hoveredPosition] : null;
if (selected.length > 0) {
editor.view.dispatch(
editor.state.tr.setMeta(blockSelectionPluginKey, { blocks: [], anchor: null }),
);
}
}
},
[editor],
);
const onElementDragEnd = useCallback(() => {
multiBlockDragRef.current = null;
dragPreviewPositionsRef.current = null;
}, []);

return {
editor,
gridDragSourceRef,
storyBlockDragContext,
handleDragHandleNodeChange,
handleNodeType,
storyEditorRef,
onElementDragStart,
onElementDragEnd,
};
}
Loading
Loading