From d2b9809a55d422be34dc06ace2c9cc78515959ce Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 21 Jul 2026 15:26:12 +0200 Subject: [PATCH 1/8] feat: Added flexible chart grids. Can now reorder, resize, regroup, varied widths --- apps/backend/src/agents/tools/story.ts | 3 +- apps/backend/src/utils/story-html.tsx | 24 +- apps/backend/src/utils/story-summary.ts | 13 +- apps/backend/tests/story-html.test.ts | 32 + .../hooks/use-story-viewer-version-actions.ts | 22 +- .../hooks/use-story-viewer-versions.ts | 4 +- .../side-panel/story-chart-embed.tsx | 47 +- .../components/side-panel/story-editor.tsx | 929 +++++++++++++++++- .../side-panel/story-table-embed.tsx | 15 +- apps/frontend/src/components/story-embeds.tsx | 1 + .../src/components/story-rendering.tsx | 21 +- .../src/components/story-thumbnail.tsx | 20 +- .../components/tool-calls/display-chart.tsx | 62 +- apps/frontend/src/styles.css | 2 +- apps/shared/src/chart-builder.tsx | 56 +- apps/shared/src/story-segments.ts | 291 +++++- apps/shared/src/story-validation.ts | 29 +- apps/shared/src/tools/story.ts | 2 +- apps/shared/src/types.ts | 2 +- apps/shared/tests/chart-builder.test.tsx | 74 ++ apps/shared/tests/story-segments.test.ts | 388 ++++++++ apps/shared/tests/story-validation.test.ts | 46 + 22 files changed, 1958 insertions(+), 125 deletions(-) create mode 100644 apps/backend/tests/story-html.test.ts create mode 100644 apps/shared/tests/story-segments.test.ts diff --git a/apps/backend/src/agents/tools/story.ts b/apps/backend/src/agents/tools/story.ts index ba91de862..21b5b6f41 100644 --- a/apps/backend/src/agents/tools/story.ts +++ b/apps/backend/src/agents/tools/story.ts @@ -16,7 +16,8 @@ export default createTool({ 'or "replace" to overwrite the entire content (producing a new version).', 'Charts are embedded via .', 'SQL result tables are embedded via .', - 'Use ... to display charts side by side in a responsive grid.', + 'Use ... to place 2–4 charts/tables side by side; its direct /
blocks are the columns.', + 'For unequal columns add widths="w1,w2,..." to the — 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.', 'A story can also be refered as a "canva", an "artifact" or a "report".', 'Users may edit stories directly; the tool result always reflects the latest version, including user edits.', 'Unless explicitly stated, dont use the stories to display a chart, but the display_chart tool.', diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index 88799f68f..e355e3431 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -87,7 +87,7 @@ function StorySegment({ segment, queryData }: { segment: Segment; queryData: Que case 'table': return ; case 'grid': - return ; + return ; } } @@ -100,30 +100,16 @@ function MarkdownBlock({ content }: { content: string }) { } function GridBlock({ - cols: _cols, - segments, + segment, queryData, }: { - cols: number; - segments: Segment[]; + segment: Extract; queryData: QueryDataMap | null; }) { - const allKpi = segments.every((s) => s.type === 'chart' && s.chart.chartType === 'kpi_card'); - if (allKpi) { - return ( -
- {segments.map((seg, i) => ( -
- -
- ))} -
- ); - } return ( <> - {segments.map((seg, i) => ( - + {segment.children.map((child, i) => ( + ))} ); diff --git a/apps/backend/src/utils/story-summary.ts b/apps/backend/src/utils/story-summary.ts index 52bb2aa4c..509e16578 100644 --- a/apps/backend/src/utils/story-summary.ts +++ b/apps/backend/src/utils/story-summary.ts @@ -1,4 +1,4 @@ -import { TAG_ATTRS } from '@nao/shared/story-segments'; +import { resolveGridWidths, TAG_ATTRS } from '@nao/shared/story-segments'; import type { StorySummary, SummarySegment } from '@nao/shared/types'; export function extractStorySummary(code: string): StorySummary { @@ -8,7 +8,7 @@ export function extractStorySummary(code: string): StorySummary { function extractSegments(code: string): SummarySegment[] { const segments: SummarySegment[] = []; const blockRegex = new RegExp( - `]*)>([\\s\\S]*?)<\\/grid>||`, + `]*))?>([\\s\\S]*?)<\\/grid>||`, 'g', ); let match; @@ -22,11 +22,12 @@ function extractSegments(code: string): SummarySegment[] { } } - if (match[1] !== undefined && match[2] !== undefined) { - const attrs = parseAttributes(match[1]); - const cols = parseInt(attrs.cols || '2', 10); + if (match[2] !== undefined) { + const attrs = parseAttributes(match[1] ?? ''); const children = extractSegments(match[2]); - segments.push({ type: 'grid', cols, children }); + const cols = parseInt(attrs.cols || String(children.length || 1), 10); + const widths = resolveGridWidths(attrs.widths, children.length); + segments.push({ type: 'grid', cols, widths, children }); } else if (match[3] !== undefined) { const attrs = parseAttributes(match[3]); if (attrs.chart_type) { diff --git a/apps/backend/tests/story-html.test.ts b/apps/backend/tests/story-html.test.ts new file mode 100644 index 000000000..9a6c6e658 --- /dev/null +++ b/apps/backend/tests/story-html.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { generateStoryHtml } from '../src/utils/story-html'; + +const CHART_ONE = + ''; +const CHART_TWO = + ''; + +describe('generateStoryHtml grid flattening', () => { + it('flattens grids with widths', () => { + const html = generateStoryHtml( + { title: 'Story', code: `${CHART_ONE}${CHART_TWO}` }, + null, + ); + + expect(html).not.toContain('grid-template-columns'); + expect(html).toContain('Revenue'); + expect(html).toContain('Orders'); + }); + + it('flattens grids without widths', () => { + const html = generateStoryHtml( + { title: 'Story', code: `${CHART_ONE}${CHART_TWO}` }, + null, + ); + + expect(html).not.toContain('grid-template-columns'); + expect(html).toContain('Revenue'); + expect(html).toContain('Orders'); + }); +}); diff --git a/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts b/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts index 94f5eaed4..8fc7d3c79 100644 --- a/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts +++ b/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts @@ -32,25 +32,41 @@ export const useStoryViewerVersionActions = ({ }: UseStoryViewerVersionActionsParams) => { const queryClient = useQueryClient(); const latestStoryQueryKey = trpc.story.getLatest.queryKey({ chatId, storySlug }); + const listVersionsQueryKey = trpc.story.listVersions.queryKey({ chatId, storySlug }); const createVersionMutation = useMutation( trpc.story.createVersion.mutationOptions({ onMutate: async (variables) => { - await queryClient.cancelQueries({ queryKey: latestStoryQueryKey }); - const previousLatestStory = queryClient.getQueryData(latestStoryQueryKey); + const previousVersions = queryClient.getQueryData(listVersionsQueryKey); queryClient.setQueryData(latestStoryQueryKey, (latestStory) => latestStory && typeof latestStory === 'object' ? { ...latestStory, code: variables.code } : latestStory, ); + queryClient.setQueryData(listVersionsQueryKey, (data) => { + if (!data || !Array.isArray(data.versions) || data.versions.length === 0) { + return data; + } + const lastVersion = data.versions[data.versions.length - 1]; + return { + ...data, + versions: [...data.versions, { ...lastVersion, code: variables.code }], + }; + }); - return { previousLatestStory }; + await queryClient.cancelQueries({ queryKey: latestStoryQueryKey }); + await queryClient.cancelQueries({ queryKey: listVersionsQueryKey }); + + return { previousLatestStory, previousVersions }; }, onError: (_error, _variables, context) => { if (context?.previousLatestStory !== undefined) { queryClient.setQueryData(latestStoryQueryKey, context.previousLatestStory); } + if (context?.previousVersions !== undefined) { + queryClient.setQueryData(listVersionsQueryKey, context.previousVersions); + } }, onSuccess: () => { void queryClient.invalidateQueries({ diff --git a/apps/frontend/src/components/side-panel/hooks/use-story-viewer-versions.ts b/apps/frontend/src/components/side-panel/hooks/use-story-viewer-versions.ts index a106bfd81..0d0df7952 100644 --- a/apps/frontend/src/components/side-panel/hooks/use-story-viewer-versions.ts +++ b/apps/frontend/src/components/side-panel/hooks/use-story-viewer-versions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { trpc } from '@/main'; @@ -39,7 +39,7 @@ export const useStoryViewerVersions = ({ previousRunningRef.current = isAgentRunning; }, [isAgentRunning, queryClient, refetch, chatId, storySlug]); - useEffect(() => { + useLayoutEffect(() => { setSelectedVersionIndex(versions.length - 1); }, [versions.length]); diff --git a/apps/frontend/src/components/side-panel/story-chart-embed.tsx b/apps/frontend/src/components/side-panel/story-chart-embed.tsx index bcc17407c..5fb07d283 100644 --- a/apps/frontend/src/components/side-panel/story-chart-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-chart-embed.tsx @@ -11,6 +11,8 @@ import { useStoryChartEdit } from '@/contexts/story-chart-edit'; import { useStoryEmbedData } from '@/contexts/story-embed-data'; import { sortByDateKey } from '@/lib/charts.utils'; +const STORY_CHART_HEIGHT_CLASS = 'h-72'; + interface ChartBlock { queryId: string; chartType: string; @@ -24,7 +26,13 @@ interface ChartBlock { rawTag?: string; } -export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: ChartBlock }) { +export const StoryChartEmbed = memo(function StoryChartEmbed({ + chart, + dragHandle, +}: { + chart: ChartBlock; + dragHandle?: React.ReactNode; +}) { const agent = useOptionalAgentContext(); const embedData = useStoryEmbedData(); @@ -75,7 +83,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: const xAxisType = chart.xAxisType === 'number' ? 'number' : ('category' as const); return ( - + ); @@ -94,6 +103,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: interface StoryChartEmbedShellProps { chart: ChartBlock; availableColumns: string[]; + dragHandle?: React.ReactNode; children: React.ReactNode; } @@ -101,7 +111,7 @@ interface StoryChartEmbedShellProps { * Wraps a rendered chart with an "Edit chart" button when the surrounding story * context provides a save handler. */ -export function StoryChartEmbedShell({ chart, availableColumns, children }: StoryChartEmbedShellProps) { +export function StoryChartEmbedShell({ chart, availableColumns, dragHandle, children }: StoryChartEmbedShellProps) { const edit = useStoryChartEdit(); const [isEditOpen, setIsEditOpen] = useState(false); const canEdit = Boolean(edit && chart.rawTag); @@ -128,7 +138,7 @@ export function StoryChartEmbedShell({ chart, availableColumns, children }: Stor return (
- {(canEdit || (chart.chartType != 'kpi_card' && chart.title)) && ( + {(canEdit || dragHandle != null || (chart.chartType != 'kpi_card' && chart.title)) && (
{chart.chartType != 'kpi_card' && chart.title ? ( @@ -137,20 +147,25 @@ export function StoryChartEmbedShell({ chart, availableColumns, children }: Stor ) : (
)} - {canEdit && ( - - )} +
+ {dragHandle} + {canEdit && ( + + )} +
)} -
{children}
+
+ {children} +
{canEdit && edit && chart.rawTag && ( so that `marked` emits an "html" token // instead of folding the custom element into a paragraph token (marked only // recognises standard HTML block elements like
). - let result = code.replace(/]*>[\s\S]*?<\/grid>/g, (match) => { + let result = code.replace(/]*)?>[\s\S]*?<\/grid>/g, (match) => { return `
\n\n`; }); @@ -63,7 +82,181 @@ export function preprocessForEditor(code: string): string { // ChartBlock extension – atom node rendered as an interactive chart // --------------------------------------------------------------------------- -function ChartBlockView({ node, updateAttributes }: ReactNodeViewProps) { +const STORY_BLOCK_DRAG_TYPE = 'application/x-nao-story-block'; + +type CardOrigin = { kind: 'block'; pos: number } | { kind: 'gridColumn'; gridPos: number; columnIndex: number }; + +type StoryBlockDragSource = { + markup: string; + origin: CardOrigin; +}; + +const StoryBlockDragContext = createContext<{ + sourceRef: React.MutableRefObject; + isDragging: boolean; + setDragging: (value: boolean) => void; +} | null>(null); + +const GRID_COLUMN_DRAG_TYPE = 'application/x-nao-grid-column'; + +type GridDragSource = { + gridPos: number; + columnIndex: number; +}; + +const GridDragContext = createContext | null>(null); + +type StoryBlockDropSide = 'left' | 'right'; + +export function StoryBlockDragGrip({ node, getPos }: Pick) { + const dragContext = useContext(StoryBlockDragContext); + + const handleDragStart = useCallback( + (event: ReactDragEvent) => { + event.stopPropagation(); + const pos = getPos(); + if (typeof pos !== 'number' || !dragContext) { + return; + } + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(STORY_BLOCK_DRAG_TYPE, '1'); + dragContext.sourceRef.current = { + markup: node.attrs.rawTag as string, + origin: { kind: 'block', pos }, + }; + dragContext.setDragging(true); + }, + [dragContext, getPos, node.attrs.rawTag], + ); + + const handleDragEnd = useCallback( + (event: ReactDragEvent) => { + event.stopPropagation(); + if (dragContext) { + dragContext.setDragging(false); + dragContext.sourceRef.current = null; + } + }, + [dragContext], + ); + + return ( + + ); +} + +export function StoryBlockDropZones({ node, editor, getPos }: Pick) { + const dragContext = useContext(StoryBlockDragContext); + const gridDragSourceRef = useContext(GridDragContext); + const [hoverSide, setHoverSide] = useState(null); + const currentPos = getPos(); + const sourceOrigin = dragContext?.sourceRef.current?.origin; + const isDropTarget = + typeof currentPos === 'number' && + dragContext?.isDragging === true && + dragContext.sourceRef.current !== null && + !(sourceOrigin?.kind === 'block' && sourceOrigin.pos === currentPos); + + useEffect(() => { + if (!dragContext?.isDragging) { + setHoverSide(null); + } + }, [dragContext?.isDragging]); + + const resetDrag = useCallback(() => { + setHoverSide(null); + if (dragContext) { + dragContext.setDragging(false); + dragContext.sourceRef.current = null; + } + if (gridDragSourceRef) { + gridDragSourceRef.current = null; + } + }, [dragContext, gridDragSourceRef]); + + const handleDrop = useCallback( + (side: StoryBlockDropSide, event: ReactDragEvent) => { + event.preventDefault(); + event.stopPropagation(); + const source = dragContext?.sourceRef.current; + const targetPos = getPos(); + if ( + !source || + typeof targetPos !== 'number' || + (source.origin.kind === 'block' && source.origin.pos === targetPos) + ) { + resetDrag(); + return; + } + + const state = editor.state; + const targetMarkup = node.attrs.rawTag as string; + const leftMarkup = side === 'left' ? source.markup : targetMarkup; + const rightMarkup = side === 'left' ? targetMarkup : source.markup; + const gridNode = createBlockNode(state.schema, groupBlocksIntoGrid(leftMarkup, rightMarkup)); + if (!gridNode) { + resetDrag(); + return; + } + + const transaction = state.tr; + transaction.replaceWith(targetPos, targetPos + node.nodeSize, gridNode); + removeCardFromOrigin(transaction, state, source.origin); + editor.view.dispatch(transaction); + resetDrag(); + }, + [dragContext, editor, getPos, node, resetDrag], + ); + + return ( + isDropTarget && + (['left', 'right'] as const).map((side) => ( +
{ + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + setHoverSide(side); + }} + onDragLeave={() => { + setHoverSide(null); + }} + onDrop={(event) => { + handleDrop(side, event); + }} + > + {hoverSide === side && ( +
+ )} +
+ )) + ); +} + +function ChartBlockView({ node, editor, getPos, updateAttributes }: ReactNodeViewProps) { const rawTag = node.attrs.rawTag as string; const chart = useMemo(() => { @@ -92,9 +285,13 @@ function ChartBlockView({ node, updateAttributes }: ReactNodeViewProps) { return ( -
+
+ - + } + />
@@ -147,7 +344,7 @@ const ChartBlock = Node.create({ // TableBlock extension – atom node rendered as a SQL table // --------------------------------------------------------------------------- -function TableBlockView({ node }: ReactNodeViewProps) { +function TableBlockView({ node, editor, getPos }: ReactNodeViewProps) { const rawTag = node.attrs.rawTag as string; const table = useMemo(() => { @@ -171,8 +368,12 @@ function TableBlockView({ node }: ReactNodeViewProps) { return ( -
- +
+ + } + />
); @@ -224,21 +425,112 @@ const TableBlock = Node.create({ // GridBlock extension – atom node rendered as a grid of charts/markdown // --------------------------------------------------------------------------- -function GridBlockView({ node, updateAttributes }: ReactNodeViewProps) { +interface GridResizeDrag { + boundaryIndex: number; + contentLeft: number; + contentWidth: number; + gapWidth: number; + targetFraction: number; + widths: number[]; +} + +function createBlockNode(schema: Schema, markup: string): PMNode | null { + const trimmedMarkup = markup.trim(); + if (trimmedMarkup.startsWith('(null); + const dragRef = useRef(null); + const [liveWidths, setLiveWidths] = useState(null); + const [activeBoundary, setActiveBoundary] = useState(null); + const [dragColumnIndex, setDragColumnIndex] = useState(null); + const [dropColumnIndex, setDropColumnIndex] = useState(null); + const [blockDropIndex, setBlockDropIndex] = useState(null); - const { cols, segments } = useMemo(() => { - const gridMatch = rawContent.match(/]*)>([\s\S]*?)<\/grid>/); + const { segments, cols, widths } = useMemo(() => { + const gridMatch = rawContent.match(/]*))?>([\s\S]*?)<\/grid>/); if (!gridMatch) { - return { cols: 2, segments: [] as Segment[] }; + return { segments: [] as Segment[], cols: 2, widths: null }; } - const attrs = parseChartAttributes(gridMatch[1]); + const attrs = parseChartAttributes(gridMatch[1] ?? ''); + const { children, spans } = parseGridColumns(gridMatch[2]); return { + segments: children, cols: parseInt(attrs.cols || '2', 10), - segments: splitCodeIntoSegments(gridMatch[2]), + widths: + attrs.widths !== undefined + ? resolveGridWidths(attrs.widths, children.length) + : spans.some((span) => span > 1) + ? spans + : null, }; }, [rawContent]); + useEffect(() => { + setLiveWidths(null); + }, [rawContent]); + + useEffect(() => { + if (!storyBlockDrag?.isDragging) { + setBlockDropIndex(null); + setDropColumnIndex(null); + setDragColumnIndex(null); + } + }, [storyBlockDrag?.isDragging]); + const handleReplaceTag = useCallback( (rawTag: string, nextTag: string) => { const nextContent = replaceUniqueStoryBlockTag(rawContent, rawTag, nextTag); @@ -249,26 +541,452 @@ function GridBlockView({ node, updateAttributes }: ReactNodeViewProps) { [rawContent, updateAttributes], ); - const gridClass = getGridClass(cols); + const clearColumnDrag = useCallback(() => { + setDragColumnIndex(null); + setDropColumnIndex(null); + }, []); + + const clearBlockDrag = useCallback(() => { + setBlockDropIndex(null); + if (storyBlockDrag) { + storyBlockDrag.setDragging(false); + storyBlockDrag.sourceRef.current = null; + } + }, [storyBlockDrag]); + + const clearDrag = useCallback(() => { + clearColumnDrag(); + clearBlockDrag(); + if (gridDragSourceRef) { + gridDragSourceRef.current = null; + } + }, [clearBlockDrag, clearColumnDrag, gridDragSourceRef]); + + const handleColumnDragStart = useCallback( + (columnIndex: number, event: ReactDragEvent) => { + event.stopPropagation(); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(GRID_COLUMN_DRAG_TYPE, String(columnIndex)); + event.dataTransfer.setData(STORY_BLOCK_DRAG_TYPE, '1'); + const gridPos = getPos(); + if (typeof gridPos === 'number' && gridDragSourceRef) { + gridDragSourceRef.current = { gridPos, columnIndex }; + } + if (typeof gridPos === 'number' && storyBlockDrag) { + const columnMarkup = splitGridColumnsRaw(rawContent).columns[columnIndex] ?? ''; + storyBlockDrag.sourceRef.current = { + markup: columnMarkup, + origin: { kind: 'gridColumn', gridPos, columnIndex }, + }; + storyBlockDrag.setDragging(true); + } + setDragColumnIndex(columnIndex); + setDropColumnIndex(null); + setBlockDropIndex(null); + }, + [getPos, gridDragSourceRef, rawContent, storyBlockDrag], + ); + + const handleGridDragOver = useCallback( + (event: ReactDragEvent) => { + if (dragColumnIndex !== null) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + const grid = gridRef.current; + const bounds = grid?.getBoundingClientRect(); + if (!grid || !bounds) { + return; + } + + const columnElements = Array.from(grid.children); + const insertionIndex = columnElements.findIndex((element) => { + const columnBounds = element.getBoundingClientRect(); + return event.clientX < columnBounds.left + columnBounds.width / 2; + }); + setDropColumnIndex(insertionIndex === -1 ? columnElements.length : insertionIndex); + return; + } + + const isExternalStoryBlock = + storyBlockDrag?.isDragging === true && storyBlockDrag.sourceRef.current !== null; + if (!isExternalStoryBlock) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + const grid = gridRef.current; + if (!grid) { + return; + } + + const columnElements = Array.from(grid.children); + const insertionIndex = columnElements.findIndex((element) => { + const columnBounds = element.getBoundingClientRect(); + return event.clientX < columnBounds.left + columnBounds.width / 2; + }); + setDropColumnIndex(null); + setBlockDropIndex(insertionIndex === -1 ? segments.length : insertionIndex); + }, + [dragColumnIndex, segments.length, storyBlockDrag], + ); + + const insertExternalStoryBlock = useCallback( + (index: number) => { + const source = storyBlockDrag?.sourceRef.current; + const gridPos = getPos(); + if (!source || typeof gridPos !== 'number') { + clearDrag(); + return; + } + if (source.origin.kind === 'gridColumn' && source.origin.gridPos === gridPos) { + clearDrag(); + return; + } + + const clampedIndex = Math.max(0, Math.min(index, segments.length)); + const state = editor.state; + const newGrid = insertGridColumn(rawContent, source.markup, clampedIndex); + if (newGrid === rawContent) { + clearDrag(); + return; + } + + const transaction = state.tr; + transaction.setNodeAttribute(gridPos, 'rawContent', newGrid); + removeCardFromOrigin(transaction, state, source.origin); + editor.view.dispatch(transaction); + clearDrag(); + }, + [clearDrag, editor, getPos, rawContent, segments.length, storyBlockDrag], + ); + + const handleGridDrop = useCallback( + (event: ReactDragEvent) => { + if (dragColumnIndex !== null) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + if (dropColumnIndex !== null) { + const targetIndex = dropColumnIndex > dragColumnIndex ? dropColumnIndex - 1 : dropColumnIndex; + const nextRawContent = reorderGridColumns(rawContent, dragColumnIndex, targetIndex); + if (nextRawContent !== rawContent) { + updateAttributes({ rawContent: nextRawContent }); + } + } + + clearDrag(); + return; + } + + const isExternalStoryBlock = + storyBlockDrag?.isDragging === true && storyBlockDrag.sourceRef.current !== null; + if (!isExternalStoryBlock) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + insertExternalStoryBlock(blockDropIndex ?? segments.length); + }, + [ + blockDropIndex, + clearDrag, + dragColumnIndex, + dropColumnIndex, + insertExternalStoryBlock, + rawContent, + segments.length, + storyBlockDrag, + updateAttributes, + ], + ); + + const handleResizeStart = useCallback( + (boundaryIndex: number, event: ReactPointerEvent) => { + const grid = gridRef.current; + if (!grid) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const bounds = grid.getBoundingClientRect(); + const gapWidth = parseFloat(getComputedStyle(grid).columnGap) || 0; + const contentWidth = bounds.width - gapWidth * (segments.length - 1); + if (contentWidth <= 0) { + return; + } + + const currentWidths = liveWidths ?? widths ?? segments.map(() => 1); + dragRef.current = { + boundaryIndex, + contentLeft: bounds.left, + contentWidth, + gapWidth, + targetFraction: 0, + widths: currentWidths, + }; + setLiveWidths(currentWidths); + setActiveBoundary(boundaryIndex); + event.currentTarget.setPointerCapture(event.pointerId); + }, + [liveWidths, segments, widths], + ); + + const updateResize = useCallback((clientX: number) => { + const drag = dragRef.current; + if (!drag) { + return null; + } + + const targetFraction = getResizeTargetFraction(drag, clientX); + drag.targetFraction = targetFraction; + const nextWidths = previewGridColumns(drag.widths, drag.boundaryIndex, targetFraction); + setLiveWidths(nextWidths); + return nextWidths; + }, []); + + const finishResize = useCallback( + (event: ReactPointerEvent, updateFromPointer: boolean) => { + const drag = dragRef.current; + if (!drag) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + if (updateFromPointer) { + drag.targetFraction = getResizeTargetFraction(drag, event.clientX); + } + const nextWidths = updateFromPointer + ? resizeGridColumns(drag.widths, drag.boundaryIndex, drag.targetFraction) + : null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setActiveBoundary(null); + + if (!nextWidths) { + setLiveWidths(null); + return; + } + const nextRawContent = setGridColumnsMarkup(rawContent, nextWidths); + if (nextRawContent !== rawContent) { + updateAttributes({ rawContent: nextRawContent }); + } else { + setLiveWidths(null); + } + }, + [rawContent, updateAttributes], + ); + + const visualWidths = liveWidths ?? widths; + const handleResizeKeyDown = useCallback( + (boundaryIndex: number, event: ReactKeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const currentWidths = liveWidths ?? widths ?? segments.map(() => 1); + const total = currentWidths.reduce((sum, width) => sum + width, 0); + const boundary = currentWidths.slice(0, boundaryIndex + 1).reduce((sum, width) => sum + width, 0); + const direction = event.key === 'ArrowLeft' ? -1 : 1; + const nextWidths = resizeGridColumns(currentWidths, boundaryIndex, boundary / total + direction / 6); + const nextRawContent = setGridColumnsMarkup(rawContent, nextWidths); + if (nextRawContent !== rawContent) { + setLiveWidths(nextWidths); + updateAttributes({ rawContent: nextRawContent }); + } + }, + [liveWidths, rawContent, segments, updateAttributes, widths], + ); + + const resizeHandlePositions = useMemo(() => { + const positionedWidths = visualWidths ?? segments.map(() => 1); + const total = positionedWidths.reduce((sum, width) => sum + width, 0); + let cumulativeWidth = 0; + return positionedWidths.slice(0, -1).map((width, index) => { + cumulativeWidth += width; + const fraction = cumulativeWidth / total; + return { + index, + left: `calc(${fraction * 100}% - ${fraction * 16 * (positionedWidths.length - 1)}px + ${(index + 0.5) * 16}px)`, + }; + }); + }, [segments, visualWidths]); + + const indicatorIndex = blockDropIndex ?? dropColumnIndex; + const dropIndicatorLeft = + indicatorIndex === null + ? null + : indicatorIndex === 0 + ? '0%' + : indicatorIndex === segments.length + ? '100%' + : resizeHandlePositions[indicatorIndex - 1]?.left; + const externalBlockSource = storyBlockDrag?.sourceRef.current; + const gridPos = getPos(); + const externalBlockActive = + storyBlockDrag?.isDragging === true && + externalBlockSource !== null && + externalBlockSource !== undefined && + !(externalBlockSource.origin.kind === 'gridColumn' && externalBlockSource.origin.gridPos === gridPos); return ( -
-
- {segments.map((segment, i) => ( -
- {segment.type === 'markdown' ? ( - {segment.content} - ) : segment.type === 'chart' ? ( - - - - ) : segment.type === 'table' ? ( - - ) : null} -
- ))} +
{ + if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) { + setBlockDropIndex(null); + } + }} + onDrop={handleGridDrop} + > +
+ {segments.map((segment, i) => { + const columnGrip = + segments.length >= 2 ? ( + + ) : null; + + return ( +
+ {segment.type === 'markdown' ? ( + <> + {columnGrip !== null && ( +
+ {columnGrip} +
+ )} + {segment.content} + + ) : segment.type === 'chart' ? ( + + + + ) : segment.type === 'table' ? ( + + ) : null} +
+ ); + })}
+ {externalBlockActive && ( + <> +
{ + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + setBlockDropIndex(0); + }} + onDrop={(event) => { + event.preventDefault(); + event.stopPropagation(); + insertExternalStoryBlock(0); + }} + /> +
{ + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + setBlockDropIndex(segments.length); + }} + onDrop={(event) => { + event.preventDefault(); + event.stopPropagation(); + insertExternalStoryBlock(segments.length); + }} + /> + + )} + {dropIndicatorLeft !== null && ( +
+ )} + {resizeHandlePositions.map(({ index, left }) => ( + + ))}
); @@ -391,6 +1109,26 @@ interface StoryEditorProps { export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave }: StoryEditorProps) { const processedContent = useMemo(() => preprocessForEditor(code), [code]); const onSaveRef = useRef(onSave); + const gridDragSourceRef = useRef(null); + const storyBlockSourceRef = useRef(null); + const [isBlockDragging, setIsBlockDragging] = useState(false); + const [handleNodeType, setHandleNodeType] = useState(null); + const storyBlockDragContext = useMemo( + () => ({ + sourceRef: storyBlockSourceRef, + isDragging: isBlockDragging, + setDragging: setIsBlockDragging, + }), + [isBlockDragging], + ); + const resetDragContexts = useCallback(() => { + gridDragSourceRef.current = null; + storyBlockSourceRef.current = null; + setIsBlockDragging(false); + }, []); + const handleDragHandleNodeChange = useCallback(({ node }: { node: PMNode | null }) => { + setHandleNodeType(node?.type.name ?? null); + }, []); onSaveRef.current = onSave; const extensions = useMemo( @@ -415,6 +1153,109 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } extensions, content: processedContent, contentType: 'markdown', + editorProps: { + handleDOMEvents: { + dragover(_view, event) { + if ( + event.dataTransfer?.types.includes(GRID_COLUMN_DRAG_TYPE) || + event.dataTransfer?.types.includes(STORY_BLOCK_DRAG_TYPE) + ) { + event.preventDefault(); + } + return false; + }, + }, + handleDrop(view, event) { + const dataTransfer = event.dataTransfer; + if (dataTransfer?.types.includes(GRID_COLUMN_DRAG_TYPE)) { + try { + const source = gridDragSourceRef.current; + if (!source) { + return true; + } + + const { state } = view; + const gridNode = state.doc.nodeAt(source.gridPos); + if (!gridNode || gridNode.type.name !== 'gridBlock') { + return true; + } + + const result = popGridColumn(gridNode.attrs.rawContent as string, source.columnIndex); + if (!result) { + return true; + } + + const poppedNode = createBlockNode(state.schema, result.popped); + const remainingNode = createBlockNode(state.schema, result.remaining); + if (!poppedNode || !remainingNode) { + return true; + } + + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!coords) { + return true; + } + + const gridFrom = source.gridPos; + const gridTo = source.gridPos + gridNode.nodeSize; + const slice = new Slice(Fragment.from(poppedNode), 0, 0); + let insertPos = dropPoint(state.doc, coords.pos, slice) ?? coords.pos; + if (insertPos > gridFrom && insertPos < gridTo) { + insertPos = gridTo; + } + + const transaction = state.tr; + transaction.replaceWith(gridFrom, gridTo, remainingNode); + transaction.insert(transaction.mapping.map(insertPos, -1), poppedNode); + view.dispatch(transaction); + event.preventDefault(); + return true; + } finally { + resetDragContexts(); + } + } + + if (dataTransfer?.types.includes(STORY_BLOCK_DRAG_TYPE)) { + try { + const source = storyBlockSourceRef.current; + if (!source) { + return true; + } + + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!coords) { + return true; + } + + const node = createBlockNode(view.state.schema, source.markup); + if (!node) { + return true; + } + + const slice = new Slice(Fragment.from(node), 0, 0); + const insertPos = dropPoint(view.state.doc, coords.pos, slice) ?? coords.pos; + if (source.origin.kind === 'block') { + const originNode = view.state.doc.nodeAt(source.origin.pos); + const originTo = originNode ? source.origin.pos + originNode.nodeSize : source.origin.pos; + if (insertPos >= source.origin.pos && insertPos <= originTo) { + return true; + } + } + + const transaction = view.state.tr; + transaction.insert(insertPos, node); + removeCardFromOrigin(transaction, view.state, source.origin); + view.dispatch(transaction); + event.preventDefault(); + return true; + } finally { + resetDragContexts(); + } + } + + return false; + }, + }, }); useEffect(() => { @@ -435,16 +1276,22 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } }, [editor, code, processedContent]); return ( -
- {editor && ( - -
- -
-
- )} - -
+ + +
+ {editor && ( + + {handleNodeType === 'chartBlock' || handleNodeType === 'tableBlock' ? null : ( +
+ +
+ )} +
+ )} + +
+
+
); }); diff --git a/apps/frontend/src/components/side-panel/story-table-embed.tsx b/apps/frontend/src/components/side-panel/story-table-embed.tsx index 14418d42e..03f640403 100644 --- a/apps/frontend/src/components/side-panel/story-table-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-table-embed.tsx @@ -11,7 +11,13 @@ import { useOptionalAgentContext } from '@/contexts/agent.provider'; import { useStoryEmbedData } from '@/contexts/story-embed-data'; import { useStoryTableEdit } from '@/contexts/story-table-edit'; -export const StoryTableEmbed = memo(function StoryTableEmbed({ table }: { table: ParsedTableBlock }) { +export const StoryTableEmbed = memo(function StoryTableEmbed({ + table, + dragHandle, +}: { + table: ParsedTableBlock; + dragHandle?: React.ReactNode; +}) { const agent = useOptionalAgentContext(); const embedData = useStoryEmbedData(); @@ -52,7 +58,12 @@ export const StoryTableEmbed = memo(function StoryTableEmbed({ table }: { table: columns={columns} title={table.title} conditionalFormats={table.conditionalFormats} - headerActions={} + headerActions={ + <> + {dragHandle} + + + } /> ); }); diff --git a/apps/frontend/src/components/story-embeds.tsx b/apps/frontend/src/components/story-embeds.tsx index 504938240..4855926c9 100644 --- a/apps/frontend/src/components/story-embeds.tsx +++ b/apps/frontend/src/components/story-embeds.tsx @@ -86,6 +86,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ yAxisMin={chart.yAxisMin} yAxisMax={chart.yAxisMax} showDataLabels={chart.showDataLabels} + normalSize /> ); diff --git a/apps/frontend/src/components/story-rendering.tsx b/apps/frontend/src/components/story-rendering.tsx index 3334ea42d..b80d5bbbb 100644 --- a/apps/frontend/src/components/story-rendering.tsx +++ b/apps/frontend/src/components/story-rendering.tsx @@ -1,5 +1,5 @@ -import { getGridClass } from '@nao/shared/story-segments'; -import { Fragment, memo, useMemo } from 'react'; +import { getGridClass, getGridTemplateColumns } from '@nao/shared/story-segments'; +import { Fragment, memo } from 'react'; import { Streamdown } from 'streamdown'; import type { ParsedChartBlock, ParsedTableBlock, Segment } from '@nao/shared/story-segments'; @@ -48,6 +48,7 @@ export const SegmentList = memo(function SegmentList({ React.ReactNode; renderTable: (table: ParsedTableBlock, key: number) => React.ReactNode; }) { - const gridClass = useMemo(() => getGridClass(cols), [cols]); - return (
-
+
{children.map((segment, i) => (
{segment.type === 'markdown' ? ( @@ -88,6 +98,7 @@ const StoryGrid = memo(function StoryGrid({ ) : segment.type === 'grid' ? ( ; case 'grid': - return ; + return ; } } @@ -313,11 +314,22 @@ function TableSilhouette() { ); } -function GridSilhouette({ cols, children }: { cols: number; children: SummarySegment[] }) { +function GridSilhouette({ + cols, + widths, + children, +}: { + cols: number; + widths: number[] | null; + children: SummarySegment[]; +}) { const gridCols = Math.min(cols, 3); + const gridTemplateColumns = widths !== null ? getGridTemplateColumns(widths) : `repeat(${gridCols}, 1fr)`; + const visibleChildren = widths !== null ? children.slice(0, widths.length * 2) : children.slice(0, gridCols * 2); + return ( -
- {children.slice(0, gridCols * 2).map((child, i) => ( +
+ {visibleChildren.map((child, i) => ( ))}
diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 5b19721f4..8523aad32 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -2,7 +2,7 @@ import { buildChart, bucketPieData, buildStoryChartBlock, labelize } from '@nao/ import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Code, Download, FilePlus, Pencil } from 'lucide-react'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { useOptionalAgentContext } from '../../contexts/agent.provider'; import GraphLoaderAnimated from '../icons/graph-loader-animated'; @@ -31,12 +31,19 @@ import { } from '@/lib/charts.utils'; import { useDateFormat } from '@/hooks/use-date-format'; import { useChatId } from '@/hooks/use-chat-id'; +import { useResizeObserver } from '@/hooks/use-resize-observer'; import { useSidePanel } from '@/contexts/side-panel'; import { StoryViewer } from '@/components/side-panel/story-viewer'; import { SidePanelContent } from '@/components/side-panel/sql-editor'; const Colors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']; const EMPTY_MESSAGES: UIMessage[] = []; +const PIE_LEGEND_BREAKPOINT = 280; +const COMPACT_XAXIS_BREAKPOINT = 360; +const CHAR_WIDTH_RATIO = 0.6; +const ANGLE_COS = Math.cos((35 * Math.PI) / 180); +const MIN_TICK_FONT = 9; +const MAX_TICK_FONT = 12; export const DisplayChartToolCall = ({ toolPart: { state, input, output, toolCallId }, @@ -315,6 +322,7 @@ export interface ChartDisplayProps { yAxisMin?: number; yAxisMax?: number; showDataLabels?: boolean; + normalSize?: boolean; } export const ChartDisplay = memo(function ChartDisplay({ @@ -329,8 +337,14 @@ export const ChartDisplay = memo(function ChartDisplay({ yAxisMin, yAxisMax, showDataLabels, + normalSize = false, }: ChartDisplayProps) { const dateFormat = useDateFormat(); + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + useResizeObserver(containerRef, (element) => { + setWidth(element.getBoundingClientRect().width); + }); const xAxisKey = useMemo(() => resolveDataKey(data, xAxisKeyProp), [data, xAxisKeyProp]); const series = useMemo( @@ -342,6 +356,9 @@ export const ChartDisplay = memo(function ChartDisplay({ const isPercentStacked = displayChart.isPercentStackedChartType(chartType); const isPie = displayChart.isPieChart(chartType); + const compactPieLegend = isPie && width > 0 && width < PIE_LEGEND_BREAKPOINT; + const pieCenteringClass = isPie && !compactPieLegend ? 'mx-auto max-w-[480px]' : ''; + const compactXAxis = !isPie && width > 0 && width < COMPACT_XAXIS_BREAKPOINT; const pieValueKey = series[0]?.data_key ?? ''; const pieData = useMemo( () => (isPie ? bucketPieData(data, xAxisKey, pieValueKey) : data), @@ -409,6 +426,21 @@ export const ChartDisplay = memo(function ChartDisplay({ () => xAxisLabelFormatter ?? ((value: string) => labelize(value, dateFormat)), [xAxisLabelFormatter, dateFormat], ); + let xAxisTickFontSize: number | undefined; + let xAxisMaxLabelChars: number | undefined; + if (compactXAxis) { + const perCategoryPx = width / Math.max(data.length, 1); + const longestLabelLen = Math.max(1, ...data.map((row) => labelFormatter(String(row[xAxisKey])).length)); + const neededFont = perCategoryPx / (longestLabelLen * CHAR_WIDTH_RATIO * ANGLE_COS); + xAxisTickFontSize = Math.round(Math.max(MIN_TICK_FONT, Math.min(MAX_TICK_FONT, neededFont))); + if (neededFont < MIN_TICK_FONT) { + xAxisTickFontSize = MIN_TICK_FONT; + xAxisMaxLabelChars = Math.max( + 3, + Math.floor(perCategoryPx / (MIN_TICK_FONT * CHAR_WIDTH_RATIO * ANGLE_COS)), + ); + } + } const tooltipLabelFormatter = useMemo( () => (value: unknown, items: unknown) => @@ -428,6 +460,9 @@ export const ChartDisplay = memo(function ChartDisplay({ series: visibleSeries, colorFor, labelFormatter, + compactXAxis, + xAxisTickFontSize, + xAxisMaxLabelChars, showGrid, showDataLabels, margin: { top: 0, right: 0, bottom: 0, left: 0 }, @@ -447,12 +482,13 @@ export const ChartDisplay = memo(function ChartDisplay({ } @@ -466,6 +502,10 @@ export const ChartDisplay = memo(function ChartDisplay({ pieData, chartType, isPie, + compactPieLegend, + compactXAxis, + xAxisTickFontSize, + xAxisMaxLabelChars, xAxisKey, xAxisType, visibleSeries, @@ -484,11 +524,19 @@ export const ChartDisplay = memo(function ChartDisplay({ ); return ( -
+
{chartType === 'kpi_card' ? ( chartElement ) : ( - + {chartElement} )} diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 33b581655..b028b7a43 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -496,7 +496,7 @@ code.dollar:before { display: flex; align-items: center; justify-content: center; - padding-right: 0.5rem; + padding-right: 1rem; cursor: grab; } diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index b5d052018..265a206df 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -28,6 +28,7 @@ import * as displayChart from './tools/display-chart'; export const DEFAULT_COLORS = ['#104e64', '#f54900', '#009689', '#ffb900', '#fe9a00']; const AXIS_TICK = { fontSize: 12 }; +const CATEGORY_XAXIS_HEIGHT = 56; const DATA_LABEL_PROPS = { fill: 'var(--foreground, #111827)', fontSize: 11, @@ -160,6 +161,9 @@ export interface BuildChartProps { title?: string; renderTitle?: boolean; maxXAxisTicks?: number; + compactXAxis?: boolean; + xAxisTickFontSize?: number; + xAxisMaxLabelChars?: number; yAxisMin?: number; yAxisMax?: number; /** Chart background color, used as the separator between stacked segments. Pass a concrete color on surfaces where CSS vars do not resolve (backend PNG/HTML export). */ @@ -402,24 +406,43 @@ function renderCategoryXAxis({ xAxisType, xAxisInterval, labelFormatter, + compact, + tickFontSize, + maxLabelChars, }: { xAxisKey: string; xAxisType?: 'number' | 'category'; xAxisInterval?: number; labelFormatter: (value: string) => string; + compact?: boolean; + tickFontSize?: number; + maxLabelChars?: number; }) { + const tickFormatter = compact + ? (value: string) => { + const label = labelFormatter(value); + if (maxLabelChars == null) { + return label; + } + const cap = Math.max(3, maxLabelChars); + return label.length > cap ? `${label.slice(0, cap - 1)}…` : label; + } + : labelFormatter; + return ( ); } @@ -436,6 +459,9 @@ function buildBarChart(props: ResolvedProps) { children, margin, xAxisInterval, + compactXAxis, + xAxisTickFontSize, + xAxisMaxLabelChars, series, yAxisMin, yAxisMax, @@ -465,7 +491,15 @@ function buildBarChart(props: ResolvedProps) { allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> )} - {renderCategoryXAxis({ xAxisKey, xAxisType, xAxisInterval, labelFormatter })} + {renderCategoryXAxis({ + xAxisKey, + xAxisType, + xAxisInterval, + labelFormatter, + compact: compactXAxis, + tickFontSize: xAxisTickFontSize, + maxLabelChars: xAxisMaxLabelChars, + })} {children} {renderedSeries.map((s, i) => ( )} - {renderCategoryXAxis({ xAxisKey, xAxisType, xAxisInterval, labelFormatter })} + {renderCategoryXAxis({ + xAxisKey, + xAxisType, + xAxisInterval, + labelFormatter, + compact: compactXAxis, + tickFontSize: xAxisTickFontSize, + maxLabelChars: xAxisMaxLabelChars, + })} {children} {renderedSeries.map((s, i) => ( "']|"(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')*?`; +const GRID_SPAN_DIV_PATTERN = + ']*style\\s*=\\s*"[^"]*grid-column\\s*:\\s*span\\s+(\\d+)[^"]*"[^>]*>([\\s\\S]*?)<\\/div>'; + +function createStoryBlockRegex(): RegExp { + return new RegExp( + `]*))?>([\\s\\S]*?)<\\/grid>||`, + 'g', + ); +} export interface ParsedChartBlock { queryId: string; @@ -35,7 +44,7 @@ export type Segment = | { type: 'markdown'; content: string } | { type: 'chart'; chart: ParsedChartBlock } | { type: 'table'; table: ParsedTableBlock } - | { type: 'grid'; cols: number; children: Segment[] }; + | { type: 'grid'; cols: number; widths: number[] | null; children: Segment[] }; function unescapeAttributeValue(value: string): string { return value.replace(/\\(["'\\])/g, '$1'); @@ -157,6 +166,265 @@ export function getGridClass(cols: number): string { return GRID_CLASSES[Math.min(cols, 4)] ?? GRID_CLASSES[2]; } +export function resolveGridWidths(widthsAttr: string | undefined, childCount: number): number[] | null { + if (widthsAttr === undefined) { + return null; + } + + const widths = widthsAttr.split(',').map((value) => Number(value.trim())); + const isValid = widths.length === childCount && widths.every((width) => Number.isInteger(width) && width > 0); + return isValid ? widths : null; +} + +export function getGridTemplateColumns(widths: number[]): string { + return widths.map((width) => `${width}fr`).join(' '); +} + +export function previewGridColumns(widths: number[], boundaryIndex: number, targetFraction: number): number[] { + if (widths.length < 2 || boundaryIndex < 0 || boundaryIndex >= widths.length - 1) { + return widths; + } + + const total = widths.reduce((sum, width) => sum + width, 0); + const fractions = widths.map((width) => width / total); + const previousBoundary = fractions.slice(0, boundaryIndex).reduce((sum, width) => sum + width, 0); + const nextBoundary = fractions.slice(0, boundaryIndex + 2).reduce((sum, width) => sum + width, 0); + const minimumFraction = 0.05; + const clampedTarget = Math.min( + Math.max(targetFraction, previousBoundary + minimumFraction), + nextBoundary - minimumFraction, + ); + + fractions[boundaryIndex] = clampedTarget - previousBoundary; + fractions[boundaryIndex + 1] = nextBoundary - clampedTarget; + return fractions; +} + +export function resizeGridColumns(widths: number[], boundaryIndex: number, targetFraction: number): number[] { + if (widths.length < 2 || boundaryIndex < 0 || boundaryIndex >= widths.length - 1) { + return widths; + } + + const total = widths.reduce((sum, width) => sum + width, 0); + const cumulativeWidths: number[] = []; + let runningTotal = 0; + for (const width of widths) { + runningTotal += width; + cumulativeWidths.push(Math.round((runningTotal / total) * 12)); + } + for (let index = 0; index < cumulativeWidths.length - 1; index++) { + const minimum = (cumulativeWidths[index - 1] ?? 0) + 1; + const maximum = 12 - (cumulativeWidths.length - index - 1); + cumulativeWidths[index] = Math.min(Math.max(cumulativeWidths[index], minimum), maximum); + } + cumulativeWidths[cumulativeWidths.length - 1] = 12; + + const previousBoundary = boundaryIndex === 0 ? 0 : cumulativeWidths[boundaryIndex - 1]; + const nextBoundary = cumulativeWidths[boundaryIndex + 1]; + const target = targetFraction * 12; + const niceBoundaries = [2, 3, 4, 6, 8, 9, 10].filter( + (boundary) => boundary > previousBoundary && boundary < nextBoundary, + ); + const candidate = + niceBoundaries.reduce((closest, boundary) => { + if (closest === null || Math.abs(boundary - target) < Math.abs(closest - target)) { + return boundary; + } + return closest; + }, null) ?? Math.min(Math.max(Math.round(target), previousBoundary + 1), nextBoundary - 1); + + cumulativeWidths[boundaryIndex] = candidate; + const resizedWidths = cumulativeWidths.map((boundary, index) => boundary - (cumulativeWidths[index - 1] ?? 0)); + const divisor = resizedWidths.reduce(greatestCommonDivisor); + return resizedWidths.map((width) => width / divisor); +} + +export function setGridColumnsMarkup(rawGridContent: string, widths: number[]): string { + const gridMatch = rawGridContent.match(/^]*)>([\s\S]*)<\/grid>\s*$/); + if (!gridMatch) { + return rawGridContent; + } + + const cleanedInner = gridMatch[2].replace(new RegExp(GRID_SPAN_DIV_PATTERN, 'gi'), '$2').trim(); + const remainingAttributes = gridMatch[1].replace(/\s+(?:widths|cols)\s*=\s*(?:"[^"]*"|'[^']*')/gi, '').trim(); + const extraAttributes = remainingAttributes ? ` ${remainingAttributes}` : ''; + + return `\n${cleanedInner}\n`; +} + +export function splitGridColumnsRaw(rawGridContent: string): { columns: string[]; widths: number[] } { + const gridMatch = rawGridContent.match(/^]*)>([\s\S]*)<\/grid>\s*$/); + if (!gridMatch) { + return { columns: [], widths: [] }; + } + + const columns: string[] = []; + const spans: number[] = []; + const spanDivRegex = new RegExp(GRID_SPAN_DIV_PATTERN, 'gi'); + let match; + let lastIndex = 0; + + const addColumns = (content: string, span: number, combineBlocks = false) => { + const blocks = splitRawStoryBlocks(content); + if (blocks.length === 0) { + return; + } + if (combineBlocks && blocks.length > 1) { + columns.push(content.trim()); + spans.push(span); + return; + } + columns.push(...blocks); + spans.push(...blocks.map(() => span)); + }; + + while ((match = spanDivRegex.exec(gridMatch[2])) !== null) { + addColumns(gridMatch[2].slice(lastIndex, match.index), 1); + addColumns(match[2], Number(match[1]), true); + lastIndex = match.index + match[0].length; + } + addColumns(gridMatch[2].slice(lastIndex), 1); + + const attrs = parseChartAttributes(gridMatch[1]); + const resolvedWidths = resolveGridWidths(attrs.widths, columns.length); + const widths = resolvedWidths ?? (spans.some((span) => span > 1) ? spans : columns.map(() => 1)); + return { columns, widths }; +} + +export function buildGridMarkup(columns: string[], widths: number[]): string { + return `\n${columns.join('\n\n')}\n`; +} + +export function groupBlocksIntoGrid(leftMarkup: string, rightMarkup: string): string { + return buildGridMarkup([leftMarkup.trim(), rightMarkup.trim()], [1, 1]); +} + +export function insertGridColumn(rawGridContent: string, columnMarkup: string, index: number): string { + const { columns, widths } = splitGridColumnsRaw(rawGridContent); + if (columns.length === 0) { + return rawGridContent; + } + + const existingCount = columns.length; + const existingSum = widths.reduce((sum, width) => sum + width, 0); + const scaledExisting = widths.map((width) => width * existingCount); + const newColumnWidth = existingSum; + const insertionIndex = Math.min(Math.max(index, 0), columns.length); + const nextColumns = [...columns]; + nextColumns.splice(insertionIndex, 0, columnMarkup.trim()); + scaledExisting.splice(insertionIndex, 0, newColumnWidth); + return buildGridMarkup(nextColumns, reduceWidthsByGcd(scaledExisting)); +} + +export function reorderGridColumns(rawGridContent: string, fromIndex: number, toIndex: number): string { + const { columns, widths } = splitGridColumnsRaw(rawGridContent); + if ( + columns.length < 2 || + fromIndex < 0 || + fromIndex >= columns.length || + toIndex < 0 || + toIndex >= columns.length || + fromIndex === toIndex + ) { + return rawGridContent; + } + + const reorderedColumns = [...columns]; + const reorderedWidths = [...widths]; + const [movedColumn] = reorderedColumns.splice(fromIndex, 1); + const [movedWidth] = reorderedWidths.splice(fromIndex, 1); + reorderedColumns.splice(toIndex, 0, movedColumn); + reorderedWidths.splice(toIndex, 0, movedWidth); + + return buildGridMarkup(reorderedColumns, reorderedWidths); +} + +export function popGridColumn( + rawGridContent: string, + columnIndex: number, +): { remaining: string; popped: string } | null { + const { columns, widths } = splitGridColumnsRaw(rawGridContent); + if (columns.length < 2 || columnIndex < 0 || columnIndex >= columns.length) { + return null; + } + + const remainingColumns = [...columns]; + const remainingWidths = [...widths]; + const [popped] = remainingColumns.splice(columnIndex, 1); + remainingWidths.splice(columnIndex, 1); + const remaining = + remainingColumns.length >= 2 ? buildGridMarkup(remainingColumns, remainingWidths) : remainingColumns[0]; + + return { remaining, popped }; +} + +function splitRawStoryBlocks(code: string): string[] { + const blocks: string[] = []; + const blockRegex = createStoryBlockRegex(); + let match; + let lastIndex = 0; + + while ((match = blockRegex.exec(code)) !== null) { + const markdown = code.slice(lastIndex, match.index).trim(); + if (markdown) { + blocks.push(markdown); + } + blocks.push(match[0].trim()); + lastIndex = match.index + match[0].length; + } + + const markdown = code.slice(lastIndex).trim(); + if (markdown) { + blocks.push(markdown); + } + return blocks; +} + +export function parseGridColumns(inner: string): { children: Segment[]; spans: number[] } { + const children: Segment[] = []; + const spans: number[] = []; + const spanDivRegex = new RegExp(GRID_SPAN_DIV_PATTERN, 'gi'); + let match; + let lastIndex = 0; + + const addColumns = (content: string, span: number, combineSegments = false) => { + const segments = splitCodeIntoSegments(content); + if (segments.length === 0) { + return; + } + if (combineSegments && segments.length > 1) { + children.push({ type: 'grid', cols: 1, widths: null, children: segments }); + spans.push(span); + return; + } + children.push(...segments); + spans.push(...segments.map(() => span)); + }; + + while ((match = spanDivRegex.exec(inner)) !== null) { + addColumns(inner.slice(lastIndex, match.index), 1); + addColumns(match[2], Number(match[1]), true); + lastIndex = match.index + match[0].length; + } + + addColumns(inner.slice(lastIndex), 1); + return { children, spans }; +} + +function greatestCommonDivisor(left: number, right: number): number { + let a = Math.abs(left); + let b = Math.abs(right); + while (b !== 0) { + [a, b] = [b, a % b]; + } + return a; +} + +function reduceWidthsByGcd(widths: number[]): number[] { + const divisor = widths.reduce(greatestCommonDivisor, 0) || 1; + return widths.map((width) => Math.max(1, Math.round(width / divisor))); +} + function tryParseSeriesJson(value: string): ParsedChartBlock['series'] | null { try { const parsed = JSON.parse(value); @@ -197,10 +465,7 @@ function extractSeriesFromRawAttrs(attrString: string): ParsedChartBlock['series export function splitCodeIntoSegments(code: string): Segment[] { const segments: Segment[] = []; - const blockRegex = new RegExp( - `]*)>([\\s\\S]*?)<\\/grid>||`, - 'g', - ); + const blockRegex = createStoryBlockRegex(); let match; let lastIndex = 0; @@ -212,11 +477,17 @@ export function splitCodeIntoSegments(code: string): Segment[] { } } - if (match[1] !== undefined && match[2] !== undefined) { - const gridAttrs = parseChartAttributes(match[1]); - const cols = parseInt(gridAttrs.cols || '2', 10); - const gridChildren = splitCodeIntoSegments(match[2]); - segments.push({ type: 'grid', cols, children: gridChildren }); + if (match[2] !== undefined) { + const gridAttrs = parseChartAttributes(match[1] ?? ''); + const { children, spans } = parseGridColumns(match[2]); + const cols = parseInt(gridAttrs.cols || String(children.length || 1), 10); + const widths = + gridAttrs.widths !== undefined + ? resolveGridWidths(gridAttrs.widths, children.length) + : spans.some((span) => span > 1) + ? spans + : null; + segments.push({ type: 'grid', cols, widths, children }); } else if (match[3] !== undefined) { const chart = parseChartBlock(match[3]); if (chart) { diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts index 6d4fa3b3a..5945fb8b3 100644 --- a/apps/shared/src/story-validation.ts +++ b/apps/shared/src/story-validation.ts @@ -1,4 +1,4 @@ -import { parseChartAttributes, TAG_ATTRS } from './story-segments'; +import { parseChartAttributes, splitCodeIntoSegments, TAG_ATTRS } from './story-segments'; export interface StoryValidationError { message: string; @@ -255,6 +255,33 @@ function validateGridBlocks(code: string): StoryValidationError[] { }); } } + + if (attrs.widths !== undefined) { + const widthValues = attrs.widths.split(','); + const hasInvalidWidth = widthValues.some((value) => { + const width = Number(value.trim()); + return !Number.isInteger(width) || width <= 0; + }); + if (hasInvalidWidth) { + errors.push({ + message: 'Grid `widths` must be a comma-separated list of positive integers.', + line: position.line, + column: position.column, + length: match[0].length, + }); + } + + const innerContent = code.slice(openTagRegex.lastIndex, closeIdx); + const childCount = splitCodeIntoSegments(innerContent).length; + if (widthValues.length !== childCount) { + errors.push({ + message: `Grid \`widths\` has ${widthValues.length} values but the grid has ${childCount} columns.`, + line: position.line, + column: position.column, + length: match[0].length, + }); + } + } } return errors; diff --git a/apps/shared/src/tools/story.ts b/apps/shared/src/tools/story.ts index 3a84617e0..b453a06db 100644 --- a/apps/shared/src/tools/story.ts +++ b/apps/shared/src/tools/story.ts @@ -19,7 +19,7 @@ export const InputSchema = z.object({ .string() .optional() .describe( - 'The markdown content. Required for "create" (initial content) and "replace" (new content). Can include charts via blocks and SQL tables via
blocks. Use ... to lay out charts side by side in a responsive grid.', + 'The markdown content. Required for "create" (initial content) and "replace" (new content). Can include charts via blocks and SQL tables via
blocks. Use ... to lay out 2–4 charts/tables side by side, optionally with widths="2,1" (comma-separated positive integers, one per column) for unequal column widths.', ), search: z.string().optional().describe('The exact text to find in the current story code. Required for "update".'), replace: z.string().optional().describe('The replacement text. Required for "update".'), diff --git a/apps/shared/src/types.ts b/apps/shared/src/types.ts index 7e64936ac..0dd975574 100644 --- a/apps/shared/src/types.ts +++ b/apps/shared/src/types.ts @@ -64,7 +64,7 @@ export type SummarySegment = | { type: 'text'; content: string } | { type: 'chart'; chartType: string; title: string; kpiCount?: number } | { type: 'table'; title: string } - | { type: 'grid'; cols: number; children: SummarySegment[] }; + | { type: 'grid'; cols: number; widths: number[] | null; children: SummarySegment[] }; export type StorySummary = { segments: SummarySegment[]; diff --git a/apps/shared/tests/chart-builder.test.tsx b/apps/shared/tests/chart-builder.test.tsx index 7eff853b5..12d186218 100644 --- a/apps/shared/tests/chart-builder.test.tsx +++ b/apps/shared/tests/chart-builder.test.tsx @@ -63,12 +63,86 @@ describe('buildChart', () => { expect(yAxis?.props.domain).toEqual([0, 60]); }); + + it('shows and angles every compact category label with a custom tick font size', () => { + const xAxis = getXAxis( + buildChart({ + data: [{ name: 'A very long category', value: 10 }], + chartType: 'bar', + xAxisKey: 'name', + xAxisType: 'category', + series: [{ data_key: 'value' }], + compactXAxis: true, + xAxisTickFontSize: 9, + labelFormatter: (value) => value, + }), + ); + + expect(xAxis?.props.interval).toBe(0); + expect(xAxis?.props.angle).toBe(-35); + expect(xAxis?.props.textAnchor).toBe('end'); + expect(xAxis?.props.height).toBe(56); + expect(xAxis?.props.tick).toEqual({ fontSize: 9 }); + expect(xAxis?.props.tickFormatter('A very long category')).toBe('A very long category'); + }); + + it('reserves the fixed category axis height when labels are not compact', () => { + const xAxis = getXAxis( + buildChart({ + data: [{ name: 'Category', value: 10 }], + chartType: 'bar', + xAxisKey: 'name', + xAxisType: 'category', + series: [{ data_key: 'value' }], + }), + ); + + expect(xAxis?.props.height).toBe(56); + expect(xAxis?.props.angle).toBeUndefined(); + }); + + it('does not truncate compact category labels without a character limit', () => { + const xAxis = getXAxis( + buildChart({ + data: [{ name: 'A very long category', value: 10 }], + chartType: 'bar', + xAxisKey: 'name', + xAxisType: 'category', + series: [{ data_key: 'value' }], + compactXAxis: true, + labelFormatter: (value) => value, + }), + ); + + expect(xAxis?.props.tickFormatter('A very long category')).toBe('A very long category'); + }); + + it('truncates compact category labels to the configured character limit', () => { + const xAxis = getXAxis( + buildChart({ + data: [{ name: 'A very long category', value: 10 }], + chartType: 'bar', + xAxisKey: 'name', + xAxisType: 'category', + series: [{ data_key: 'value' }], + compactXAxis: true, + xAxisMaxLabelChars: 6, + labelFormatter: (value) => value, + }), + ); + + expect(xAxis?.props.tickFormatter('A very long category')).toBe('A ver…'); + }); }); function getYAxis(chart: ReactElement): ReactElement | undefined { return flattenChildren(chart.props.children).find((child) => child.type.displayName === 'YAxis'); } +function getXAxis(chart: ReactElement): ReactElement | undefined { + return flattenChildren(chart.props.children).find((child) => child.type.displayName === 'XAxis'); +} + function flattenChildren(children: unknown): ReactElement[] { if (Array.isArray(children)) { return children.flatMap(flattenChildren); diff --git a/apps/shared/tests/story-segments.test.ts b/apps/shared/tests/story-segments.test.ts new file mode 100644 index 000000000..a2a7d6967 --- /dev/null +++ b/apps/shared/tests/story-segments.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from 'vitest'; + +import { + getGridTemplateColumns, + groupBlocksIntoGrid, + insertGridColumn, + popGridColumn, + previewGridColumns, + reorderGridColumns, + resizeGridColumns, + resolveGridWidths, + setGridColumnsMarkup, + splitCodeIntoSegments, + splitGridColumnsRaw, +} from '../src/story-segments'; + +const CHART_ONE = + ''; +const CHART_TWO = + ''; +const CHART_THREE = + ''; + +describe('grid widths', () => { + it('resolves valid widths', () => { + expect(resolveGridWidths('3,1', 2)).toEqual([3, 1]); + }); + + it('returns null when the count is wrong', () => { + expect(resolveGridWidths('3,1', 3)).toBeNull(); + }); + + it('returns null for invalid values', () => { + expect(resolveGridWidths('1.5,-1', 2)).toBeNull(); + }); + + it('returns null when absent', () => { + expect(resolveGridWidths(undefined, 2)).toBeNull(); + }); + + it('builds a CSS grid template', () => { + expect(getGridTemplateColumns([3, 1])).toBe('3fr 1fr'); + }); +}); + +describe('groupBlocksIntoGrid', () => { + it('groups two blocks in left-to-right order', () => { + const markup = groupBlocksIntoGrid(CHART_ONE, CHART_TWO); + + expect(markup).toBe(`\n${CHART_ONE}\n\n${CHART_TWO}\n`); + expect(splitCodeIntoSegments(markup)).toMatchObject([ + { + type: 'grid', + widths: [1, 1], + children: [ + { type: 'chart', chart: { title: 'Revenue' } }, + { type: 'chart', chart: { title: 'Orders' } }, + ], + }, + ]); + }); +}); + +describe('insertGridColumn', () => { + it('gives an inserted column one third while preserving existing proportions', () => { + const markup = insertGridColumn(`\n${CHART_ONE}\n\n${CHART_TWO}\n`, CHART_THREE, 1); + const { columns, widths } = splitGridColumnsRaw(markup); + const total = widths.reduce((sum, width) => sum + width, 0); + + expect(columns).toEqual([CHART_ONE, CHART_THREE, CHART_TWO]); + expect(widths).toEqual([3, 2, 1]); + expect(widths[1] / total).toBe(1 / 3); + expect(widths[0] / widths[2]).toBe(3); + expect(splitCodeIntoSegments(markup)[0]).toMatchObject({ + type: 'grid', + widths: [3, 2, 1], + children: [ + { type: 'chart', chart: { title: 'Revenue' } }, + { type: 'chart', chart: { title: 'Profit' } }, + { type: 'chart', chart: { title: 'Orders' } }, + ], + }); + }); + + it('appends to equal columns with equal thirds', () => { + const grid = `\n${CHART_ONE}\n\n${CHART_TWO}\n`; + const { columns, widths } = splitGridColumnsRaw(insertGridColumn(grid, CHART_THREE, 2)); + + expect(columns).toEqual([CHART_ONE, CHART_TWO, CHART_THREE]); + expect(widths).toEqual([1, 1, 1]); + expect(widths[2] / widths.reduce((sum, width) => sum + width, 0)).toBe(1 / 3); + }); + + it('prepends one third while preserving a two-to-one ratio', () => { + const grid = `\n${CHART_ONE}\n\n${CHART_TWO}\n`; + const { columns, widths } = splitGridColumnsRaw(insertGridColumn(grid, CHART_THREE, 0)); + const total = widths.reduce((sum, width) => sum + width, 0); + + expect(columns).toEqual([CHART_THREE, CHART_ONE, CHART_TWO]); + expect(widths).toEqual([3, 4, 2]); + expect(widths[0] / total).toBe(1 / 3); + expect(widths[1] / widths[2]).toBe(2); + }); + + it('clamps insertion indexes to the grid edges', () => { + const grid = `\n${CHART_ONE}\n\n${CHART_TWO}\n`; + + expect(splitGridColumnsRaw(insertGridColumn(grid, CHART_THREE, -1)).columns).toEqual([ + CHART_THREE, + CHART_ONE, + CHART_TWO, + ]); + expect(splitGridColumnsRaw(insertGridColumn(grid, CHART_THREE, 100)).columns).toEqual([ + CHART_ONE, + CHART_TWO, + CHART_THREE, + ]); + }); + + it('returns non-grid input unchanged', () => { + expect(insertGridColumn(CHART_ONE, CHART_TWO, 1)).toBe(CHART_ONE); + }); +}); + +describe('resizeGridColumns', () => { + it.each([ + [0.75, [3, 1]], + [0.25, [1, 3]], + [0.5, [1, 1]], + [0.66, [2, 1]], + [0.33, [1, 2]], + ])('snaps two columns at target %s', (target, expected) => { + expect(resizeGridColumns([1, 1], 0, target)).toEqual(expected); + }); + + it('changes only the adjacent columns in a three-column grid', () => { + const resized = resizeGridColumns([1, 1, 1], 0, 0.5); + const total = resized.reduce((sum, width) => sum + width, 0); + + expect(resized).toHaveLength(3); + expect(resized.every((width) => width > 0)).toBe(true); + expect(Math.round((resized[2] / total) * 12)).toBe(4); + }); + + it('returns the original widths for invalid boundaries or a single column', () => { + const widths = [1, 1]; + const singleColumn = [1]; + + expect(resizeGridColumns(widths, -1, 0.5)).toBe(widths); + expect(resizeGridColumns(widths, 1, 0.5)).toBe(widths); + expect(resizeGridColumns(singleColumn, 0, 0.5)).toBe(singleColumn); + }); +}); + +describe('previewGridColumns', () => { + it('previews a smooth two-column split', () => { + const preview = previewGridColumns([1, 1], 0, 0.7); + + expect(preview[0]).toBeCloseTo(0.7); + expect(preview[1]).toBeCloseTo(0.3); + }); + + it('keeps non-adjacent column fractions unchanged', () => { + const preview = previewGridColumns([1, 1, 1], 0, 0.5); + + expect(preview[0]).toBeCloseTo(0.5); + expect(preview[1]).toBeCloseTo(1 / 6); + expect(preview[2]).toBeCloseTo(1 / 3); + }); + + it('clamps adjacent columns to a minimum fraction', () => { + const preview = previewGridColumns([1, 1], 0, 0.99); + + expect(preview[1]).toBeCloseTo(0.05); + }); + + it('returns the original widths for an invalid boundary', () => { + const widths = [1, 1]; + + expect(previewGridColumns(widths, 1, 0.5)).toBe(widths); + }); +}); + +describe('setGridColumnsMarkup', () => { + it('replaces legacy spans with explicit widths', () => { + const legacyGrid = ` +${CHART_ONE} +
+${CHART_TWO} +
+
`; + const markup = setGridColumnsMarkup(legacyGrid, [1, 2]); + + expect(markup).toContain(''); + expect(markup).toContain(CHART_ONE); + expect(markup).toContain(CHART_TWO); + expect(markup).not.toContain(''); + expect(markup).not.toContain('cols='); + }); + + it('replaces cols while keeping both charts intact', () => { + const markup = setGridColumnsMarkup(`\n${CHART_ONE}\n${CHART_TWO}\n`, [3, 1]); + + expect(markup).toContain(''); + expect(markup).toContain(CHART_ONE); + expect(markup).toContain(CHART_TWO); + expect(markup).not.toContain('cols='); + }); + + it('round-trips through grid parsing', () => { + const markup = setGridColumnsMarkup(`\n${CHART_ONE}\n${CHART_TWO}\n`, [3, 1]); + const segments = splitCodeIntoSegments(markup); + + expect(segments[0]).toMatchObject({ + type: 'grid', + widths: [3, 1], + children: [{ type: 'chart' }, { type: 'chart' }], + }); + }); +}); + +describe('splitGridColumnsRaw', () => { + it('preserves raw chart tags and resolves explicit widths', () => { + const result = splitGridColumnsRaw(`${CHART_ONE}${CHART_TWO}`); + + expect(result).toEqual({ + columns: [CHART_ONE, CHART_TWO], + widths: [1, 2], + }); + }); + + it('strips legacy span wrappers and derives widths', () => { + const result = splitGridColumnsRaw( + `${CHART_ONE}
${CHART_TWO}
`, + ); + + expect(result).toEqual({ + columns: [CHART_ONE, CHART_TWO], + widths: [1, 2], + }); + }); +}); + +describe('reorderGridColumns', () => { + it('moves a column and its width together', () => { + const markup = reorderGridColumns(`${CHART_ONE}${CHART_TWO}${CHART_THREE}`, 0, 2); + const { columns, widths } = splitGridColumnsRaw(markup); + + expect(columns).toEqual([CHART_TWO, CHART_THREE, CHART_ONE]); + expect(widths).toEqual([2, 3, 1]); + expect(splitCodeIntoSegments(markup)[0]).toMatchObject({ + type: 'grid', + widths: [2, 3, 1], + children: [ + { type: 'chart', chart: { title: 'Orders' } }, + { type: 'chart', chart: { title: 'Profit' } }, + { type: 'chart', chart: { title: 'Revenue' } }, + ], + }); + }); + + it('returns the original markup for invalid moves and single columns', () => { + const grid = `${CHART_ONE}${CHART_TWO}`; + const singleColumn = `${CHART_ONE}`; + + expect(reorderGridColumns(grid, -1, 1)).toBe(grid); + expect(reorderGridColumns(grid, 0, 2)).toBe(grid); + expect(reorderGridColumns(singleColumn, 0, 0)).toBe(singleColumn); + }); +}); + +describe('popGridColumn', () => { + it('pops a column and keeps the remaining relative widths', () => { + const grid = `\n${CHART_ONE}\n\n${CHART_TWO}\n\n${CHART_THREE}\n`; + const result = popGridColumn(grid, 2); + + expect(result).toEqual({ + popped: CHART_THREE, + remaining: `\n${CHART_ONE}\n\n${CHART_TWO}\n`, + }); + + const segments = splitCodeIntoSegments(`${result?.popped}\n\n${result?.remaining}`); + expect(segments).toHaveLength(2); + expect(segments[0]).toMatchObject({ + type: 'chart', + chart: { title: 'Profit' }, + }); + expect(segments[1]).toMatchObject({ + type: 'grid', + widths: [1, 1], + children: [ + { type: 'chart', chart: { title: 'Revenue' } }, + { type: 'chart', chart: { title: 'Orders' } }, + ], + }); + }); + + it('collapses a two-column grid to the remaining full-width block', () => { + const grid = `\n${CHART_ONE}\n\n${CHART_TWO}\n`; + + expect(popGridColumn(grid, 0)).toEqual({ + popped: CHART_ONE, + remaining: CHART_TWO, + }); + }); + + it('returns null for invalid columns and single-column grids', () => { + const grid = `${CHART_ONE}${CHART_TWO}`; + const singleColumn = `${CHART_ONE}`; + + expect(popGridColumn(grid, -1)).toBeNull(); + expect(popGridColumn(grid, 2)).toBeNull(); + expect(popGridColumn(singleColumn, 0)).toBeNull(); + }); +}); + +describe('splitCodeIntoSegments grid widths', () => { + it('converts legacy span divs into grid widths without empty columns', () => { + const segments = splitCodeIntoSegments( + `${CHART_ONE}
${CHART_TWO}
`, + ); + const grid = segments[0]; + + expect(grid).toMatchObject({ + type: 'grid', + cols: 3, + widths: [1, 2], + children: [ + { type: 'chart', chart: { chartType: 'line', title: 'Revenue' } }, + { type: 'chart', chart: { chartType: 'bar', title: 'Orders' } }, + ], + }); + if (grid.type !== 'grid') { + throw new Error('Expected a grid segment'); + } + expect(grid.children).toHaveLength(2); + expect( + grid.children.some( + (child) => + child.type === 'markdown' && (child.content.includes('')), + ), + ).toBe(false); + }); + + it('includes valid resolved widths', () => { + const segments = splitCodeIntoSegments(`${CHART_ONE}${CHART_TWO}`); + + expect(segments).toHaveLength(1); + expect(segments[0]).toMatchObject({ + type: 'grid', + widths: [3, 1], + children: [{ type: 'chart' }, { type: 'chart' }], + }); + }); + + it('leaves widths null for legacy grids', () => { + const segments = splitCodeIntoSegments(`${CHART_ONE}${CHART_TWO}`); + + expect(segments[0]).toMatchObject({ + type: 'grid', + cols: 2, + widths: null, + children: [{ type: 'chart' }, { type: 'chart' }], + }); + }); + + it('uses explicit widths instead of legacy spans', () => { + const segments = splitCodeIntoSegments( + `${CHART_ONE}
${CHART_TWO}
`, + ); + + expect(segments[0]).toMatchObject({ + type: 'grid', + widths: [3, 1], + children: [{ type: 'chart' }, { type: 'chart' }], + }); + }); + + it('leaves widths null for invalid explicit widths', () => { + const segments = splitCodeIntoSegments(`${CHART_ONE}${CHART_TWO}`); + + expect(segments[0]).toMatchObject({ + type: 'grid', + widths: null, + }); + }); +}); diff --git a/apps/shared/tests/story-validation.test.ts b/apps/shared/tests/story-validation.test.ts index 2b1f864a2..c576f3f86 100644 --- a/apps/shared/tests/story-validation.test.ts +++ b/apps/shared/tests/story-validation.test.ts @@ -134,6 +134,52 @@ describe('validateStoryCode', () => { expect(errors.some((e) => e.message.includes('between 1 and 4'))).toBe(true); }); + it('accepts valid widths', () => { + const code = [ + '', + '', + '', + '', + ].join('\n'); + expect(validateStoryCode(code)).toEqual([]); + }); + + it('flags widths with the wrong count', () => { + const code = [ + '', + '', + '', + '', + ].join('\n'); + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message === 'Grid `widths` has 1 values but the grid has 2 columns.')).toBe( + true, + ); + }); + + it('flags non-integer widths', () => { + const code = [ + '', + '', + '', + '', + ].join('\n'); + const errors = validateStoryCode(code); + expect( + errors.some((e) => e.message === 'Grid `widths` must be a comma-separated list of positive integers.'), + ).toBe(true); + }); + + it('accepts a grid without widths', () => { + const code = [ + '', + '', + '', + '', + ].join('\n'); + expect(validateStoryCode(code)).toEqual([]); + }); + it('supports nested grids', () => { const code = [ '', From c6ca3795f439d9486d1f68e26d6a92e38edcaab8 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 21 Jul 2026 17:27:34 +0200 Subject: [PATCH 2/8] feat: multi-select story blocks to move them together --- .../side-panel/story-block-selection.test.ts | 126 ++++++++++ .../side-panel/story-block-selection.ts | 227 ++++++++++++++++++ .../components/side-panel/story-editor.tsx | 183 +++++++++++++- apps/frontend/src/styles.css | 8 + 4 files changed, 537 insertions(+), 7 deletions(-) create mode 100644 apps/frontend/src/components/side-panel/story-block-selection.test.ts create mode 100644 apps/frontend/src/components/side-panel/story-block-selection.ts diff --git a/apps/frontend/src/components/side-panel/story-block-selection.test.ts b/apps/frontend/src/components/side-panel/story-block-selection.test.ts new file mode 100644 index 000000000..c31477b3e --- /dev/null +++ b/apps/frontend/src/components/side-panel/story-block-selection.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment jsdom + +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BlockSelection, + blockSelectionPluginKey, + buildBlockMoveTransaction, + getSelectedBlockPositions, + isDropInsideSelection, + rangeBetween, + topLevelBlockPositions, +} from './story-block-selection'; + +function createEditor(): Editor { + return new Editor({ + extensions: [StarterKit, BlockSelection], + content: '

AA

BB

CC

', + }); +} + +function selectBlocks(editor: Editor, blocks: number[], anchor: number | null): void { + editor.view.dispatch(editor.state.tr.setMeta(blockSelectionPluginKey, { blocks, anchor })); +} + +describe('story block selection', () => { + let editor: Editor; + + beforeEach(() => { + editor = createEditor(); + }); + + afterEach(() => { + editor.destroy(); + }); + + it('lists every top-level block position', () => { + expect(topLevelBlockPositions(editor.state.doc)).toHaveLength(3); + }); + + it('builds an inclusive range between two blocks regardless of order', () => { + const [first, second, third] = topLevelBlockPositions(editor.state.doc); + expect(rangeBetween(editor.state.doc, first, third)).toEqual([first, second, third]); + expect(rangeBetween(editor.state.doc, third, first)).toEqual([first, second, third]); + expect(rangeBetween(editor.state.doc, second, second)).toEqual([second]); + }); + + it('starts with no selection', () => { + expect(getSelectedBlockPositions(editor.state)).toEqual([]); + }); + + it('stores and clears the selected blocks', () => { + const [first, , third] = topLevelBlockPositions(editor.state.doc); + selectBlocks(editor, [first, third], first); + expect(getSelectedBlockPositions(editor.state)).toEqual([first, third]); + + selectBlocks(editor, [], null); + expect(getSelectedBlockPositions(editor.state)).toEqual([]); + }); + + it('keeps the selection when an unrelated block is edited', () => { + const [first] = topLevelBlockPositions(editor.state.doc); + selectBlocks(editor, [first], first); + editor.view.dispatch(editor.state.tr.insertText(' x', editor.state.doc.content.size - 1)); + expect(getSelectedBlockPositions(editor.state)).toEqual([first]); + }); + + it('drops a selected block once it is deleted', () => { + const [, second, third] = topLevelBlockPositions(editor.state.doc); + selectBlocks(editor, [second, third], second); + editor.view.dispatch(editor.state.tr.delete(third, editor.state.doc.content.size)); + expect(getSelectedBlockPositions(editor.state)).toEqual([second]); + }); + + describe('isDropInsideSelection', () => { + it('is true when dropping between two adjacent selected blocks', () => { + const [first, second] = topLevelBlockPositions(editor.state.doc); + expect(isDropInsideSelection(editor.state.doc, second, [first, second])).toBe(true); + }); + + it('is false when dropping into the gap around an unselected middle block', () => { + const [first, second, third] = topLevelBlockPositions(editor.state.doc); + expect(isDropInsideSelection(editor.state.doc, second, [first, third])).toBe(false); + expect(isDropInsideSelection(editor.state.doc, third, [first, third])).toBe(false); + }); + + it('is false when dropping outside the selection', () => { + const [first, , third] = topLevelBlockPositions(editor.state.doc); + expect(isDropInsideSelection(editor.state.doc, first, [first, third])).toBe(false); + expect(isDropInsideSelection(editor.state.doc, editor.state.doc.content.size, [first, third])).toBe(false); + }); + }); + + describe('buildBlockMoveTransaction', () => { + it('moves a non-contiguous selection around an unselected middle block', () => { + const [a, , c] = topLevelBlockPositions(editor.state.doc); + const move = buildBlockMoveTransaction(editor.state, [a, c], c); + expect(move).not.toBeNull(); + if (!move) { + return; + } + editor.view.dispatch(move.transaction); + expect(editor.state.doc.childCount).toBe(3); + expect(editor.state.doc.textContent).toBe('BBAACC'); + }); + + it('moves a contiguous selection past a later block without dropping blocks', () => { + const [a, b, c] = topLevelBlockPositions(editor.state.doc); + const move = buildBlockMoveTransaction(editor.state, [a, b], c); + expect(move).not.toBeNull(); + if (!move) { + return; + } + editor.view.dispatch(move.transaction); + expect(editor.state.doc.childCount).toBe(3); + expect(editor.state.doc.textContent).toBe('AABBCC'); + }); + + it('returns null when dropping between two adjacent selected blocks', () => { + const [a, b] = topLevelBlockPositions(editor.state.doc); + expect(buildBlockMoveTransaction(editor.state, [a, b], b)).toBeNull(); + }); + }); +}); diff --git a/apps/frontend/src/components/side-panel/story-block-selection.ts b/apps/frontend/src/components/side-panel/story-block-selection.ts new file mode 100644 index 000000000..7ec6fb038 --- /dev/null +++ b/apps/frontend/src/components/side-panel/story-block-selection.ts @@ -0,0 +1,227 @@ +import { Extension } from '@tiptap/core'; +import { Fragment } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; + +import type { Node as PMNode } from '@tiptap/pm/model'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; + +interface BlockSelectionState { + blocks: number[]; + anchor: number | null; +} + +export const blockSelectionPluginKey = new PluginKey('blockSelection'); + +export const BlockSelection = Extension.create({ + name: 'blockSelection', + + addProseMirrorPlugins() { + return [buildBlockSelectionPlugin()]; + }, +}); + +export function getSelectedBlockPositions(state: EditorState): number[] { + return blockSelectionPluginKey.getState(state)?.blocks ?? []; +} + +function buildBlockSelectionPlugin(): Plugin { + return new Plugin({ + key: blockSelectionPluginKey, + state: { + init: () => ({ blocks: [], anchor: null }), + apply(tr, value) { + const meta = tr.getMeta(blockSelectionPluginKey) as BlockSelectionState | undefined; + if (meta !== undefined) { + return meta; + } + if (!tr.docChanged) { + return value; + } + + const valid = new Set(topLevelBlockPositions(tr.doc)); + const blocks = value.blocks + .map((position) => tr.mapping.map(position, -1)) + .filter((position) => valid.has(position)); + const mappedAnchor = value.anchor == null ? null : tr.mapping.map(value.anchor, -1); + const anchor = mappedAnchor != null && valid.has(mappedAnchor) ? mappedAnchor : null; + return { blocks, anchor }; + }, + }, + props: { + decorations(state) { + const selection = blockSelectionPluginKey.getState(state); + if (!selection?.blocks.length) { + return DecorationSet.empty; + } + + const decorations: Decoration[] = []; + for (const position of selection.blocks) { + const node = state.doc.nodeAt(position); + if (node) { + decorations.push( + Decoration.node(position, position + node.nodeSize, { + class: 'nao-block-selected', + }), + ); + } + } + return DecorationSet.create(state.doc, decorations); + }, + handleDOMEvents: { + mousedown(view, event) { + const toggle = event.metaKey || event.ctrlKey; + const range = event.shiftKey; + const current = blockSelectionPluginKey.getState(view.state) ?? { blocks: [], anchor: null }; + + if (!toggle && !range) { + if (current.blocks.length) { + view.dispatch( + view.state.tr.setMeta(blockSelectionPluginKey, { + blocks: [], + anchor: null, + }), + ); + } + return false; + } + + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (!coordinates) { + return false; + } + + const blockPosition = topLevelBlockPosAt(view, coordinates.pos, event.clientX, event.clientY); + if (blockPosition == null) { + return false; + } + + event.preventDefault(); + + if (toggle) { + const hasBlock = current.blocks.includes(blockPosition); + const blocks = hasBlock + ? current.blocks.filter((position) => position !== blockPosition) + : [...current.blocks, blockPosition].sort((first, second) => first - second); + view.dispatch( + view.state.tr.setMeta(blockSelectionPluginKey, { + blocks, + anchor: blockPosition, + }), + ); + return true; + } + + const anchor = current.anchor ?? blockPosition; + view.dispatch( + view.state.tr.setMeta(blockSelectionPluginKey, { + blocks: rangeBetween(view.state.doc, anchor, blockPosition), + anchor, + }), + ); + return true; + }, + }, + handleKeyDown(view, event) { + const selection = blockSelectionPluginKey.getState(view.state); + if (event.key !== 'Escape' || !selection?.blocks.length) { + return false; + } + + view.dispatch( + view.state.tr.setMeta(blockSelectionPluginKey, { + blocks: [], + anchor: null, + }), + ); + return true; + }, + }, + }); +} + +export function topLevelBlockPositions(doc: PMNode): number[] { + const positions: number[] = []; + doc.forEach((_node, offset) => positions.push(offset)); + return positions; +} + +export function isDropInsideSelection(doc: PMNode, insertPos: number, selected: number[]): boolean { + const selectedSet = new Set(selected); + const $insert = doc.resolve(insertPos); + const nodeBefore = $insert.nodeBefore; + const nodeAfter = $insert.nodeAfter; + const beforeSelected = nodeBefore != null && selectedSet.has(insertPos - nodeBefore.nodeSize); + const afterSelected = nodeAfter != null && selectedSet.has(insertPos); + return beforeSelected && afterSelected; +} + +export interface BlockMove { + transaction: Transaction; + insertPos: number; +} + +export function buildBlockMoveTransaction( + state: EditorState, + positions: number[], + insertPos: number, +): BlockMove | null { + const nodes = positions + .map((position) => state.doc.nodeAt(position)) + .filter((node): node is PMNode => node != null); + if (nodes.length === 0 || isDropInsideSelection(state.doc, insertPos, positions)) { + return null; + } + + const transaction = state.tr; + for (const position of [...positions].sort((first, second) => second - first)) { + const node = state.doc.nodeAt(position); + if (!node) { + continue; + } + transaction.delete(position, position + node.nodeSize); + } + + const mappedInsert = transaction.mapping.map(insertPos); + transaction.insert(mappedInsert, Fragment.fromArray(nodes)); + transaction.setMeta(blockSelectionPluginKey, { blocks: [], anchor: null }); + return { transaction, insertPos: mappedInsert }; +} + +function topLevelBlockPosAt(view: EditorView, position: number, clientX: number, clientY: number): number | null { + const $position = view.state.doc.resolve(position); + if ($position.depth > 0) { + return $position.before(1); + } + + const candidates: number[] = []; + if ($position.nodeAfter) { + candidates.push($position.pos); + } + if ($position.nodeBefore) { + candidates.push($position.pos - $position.nodeBefore.nodeSize); + } + + for (const candidate of candidates) { + const dom = view.nodeDOM(candidate); + if (!(dom instanceof HTMLElement)) { + continue; + } + const rect = dom.getBoundingClientRect(); + if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) { + return candidate; + } + } + + return candidates[0] ?? null; +} + +export function rangeBetween(doc: PMNode, first: number, second: number): number[] { + const start = Math.min(first, second); + const end = Math.max(first, second); + return topLevelBlockPositions(doc).filter((position) => position >= start && position <= end); +} diff --git a/apps/frontend/src/components/side-panel/story-editor.tsx b/apps/frontend/src/components/side-panel/story-editor.tsx index 68a2ac175..c70b4f965 100644 --- a/apps/frontend/src/components/side-panel/story-editor.tsx +++ b/apps/frontend/src/components/side-panel/story-editor.tsx @@ -21,6 +21,7 @@ import { DragHandle } from '@tiptap/extension-drag-handle-react'; import { TableKit } from '@tiptap/extension-table'; import { Markdown } from '@tiptap/markdown'; import { Fragment, Slice } from '@tiptap/pm/model'; +import { Selection } from '@tiptap/pm/state'; import { dropPoint } from '@tiptap/pm/transform'; import { EditorContent, NodeViewWrapper, ReactNodeViewRenderer, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; @@ -28,12 +29,19 @@ import { GripVertical } from 'lucide-react'; import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Streamdown } from 'streamdown'; +import { + blockSelectionPluginKey, + BlockSelection, + buildBlockMoveTransaction, + getSelectedBlockPositions, +} from './story-block-selection'; import { StoryChartEmbed } from './story-chart-embed'; import { StoryTableEmbed } from './story-table-embed'; import type { Editor, ReactNodeViewProps } from '@tiptap/react'; import type { Editor as CoreEditor } from '@tiptap/core'; import type { Node as PMNode, Schema } from '@tiptap/pm/model'; import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; import type { Segment } from '@nao/shared/story-segments'; import type { DragEvent as ReactDragEvent, @@ -55,6 +63,37 @@ function decodeFromAttr(encoded: string): string { return decodeURIComponent(atob(encoded)); } +function cloneElementWithStyles(node: HTMLElement): HTMLElement { + const clone = node.cloneNode(true) as HTMLElement; + const sources = [node, ...Array.from(node.getElementsByTagName('*'))]; + const targets = [clone, ...Array.from(clone.getElementsByTagName('*'))]; + sources.forEach((source, index) => { + const target = targets[index]; + if (!(target instanceof HTMLElement || target instanceof SVGElement)) { + return; + } + const computed = window.getComputedStyle(source as Element); + let cssText = ''; + for (const property of computed) { + cssText += `${property}:${computed.getPropertyValue(property)};`; + } + target.style.cssText = cssText; + }); + return clone; +} + +function dispatchDropWithScroll(view: EditorView, transaction: Transaction, pos: number): void { + const target = Math.max(0, Math.min(pos, transaction.doc.content.size)); + transaction.setSelection(Selection.near(transaction.doc.resolve(target))); + view.dispatch(transaction); + requestAnimationFrame(() => { + const clamped = Math.max(0, Math.min(pos, view.state.doc.content.size)); + const dom = view.nodeDOM(clamped); + const element = dom instanceof HTMLElement ? dom : (dom?.parentElement ?? null); + element?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }); +} + /** * Replaces custom ,
and tags with HTML-safe elements that * Tiptap's DOMParser can match against custom node extensions. @@ -1086,7 +1125,7 @@ const TableDeleteShortcuts = Extension.create({ const EDITOR_EXTENSIONS = [ StarterKit.configure({ - dropcursor: { width: 3, class: 'drop-cursor' }, + dropcursor: { width: 3, class: 'drop-cursor', color: false }, }), TableKit, TableDeleteShortcuts, @@ -1098,6 +1137,7 @@ const EDITOR_EXTENSIONS = [ ChartBlock, TableBlock, GridBlock, + BlockSelection, ]; interface StoryEditorProps { @@ -1111,6 +1151,10 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } const onSaveRef = useRef(onSave); const gridDragSourceRef = useRef(null); const storyBlockSourceRef = useRef(null); + const multiBlockDragRef = useRef(null); + const handleNodePosRef = useRef(null); + const dragPreviewPositionsRef = useRef(null); + const storyEditorRef = useRef(null); const [isBlockDragging, setIsBlockDragging] = useState(false); const [handleNodeType, setHandleNodeType] = useState(null); const storyBlockDragContext = useMemo( @@ -1124,10 +1168,12 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } 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; @@ -1167,6 +1213,37 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } }, 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; @@ -1206,8 +1283,9 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } const transaction = state.tr; transaction.replaceWith(gridFrom, gridTo, remainingNode); - transaction.insert(transaction.mapping.map(insertPos, -1), poppedNode); - view.dispatch(transaction); + const poppedPos = transaction.mapping.map(insertPos, -1); + transaction.insert(poppedPos, poppedNode); + dispatchDropWithScroll(view, transaction, poppedPos); event.preventDefault(); return true; } finally { @@ -1245,7 +1323,7 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } const transaction = view.state.tr; transaction.insert(insertPos, node); removeCardFromOrigin(transaction, view.state, source.origin); - view.dispatch(transaction); + dispatchDropWithScroll(view, transaction, transaction.mapping.map(insertPos, -1)); event.preventDefault(); return true; } finally { @@ -1258,6 +1336,61 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } }, }); + 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 () => { @@ -1278,9 +1411,45 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } return ( -
+
{editor && ( - + { + 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, + }), + ); + } + } + }} + onElementDragEnd={() => { + multiBlockDragRef.current = null; + dragPreviewPositionsRef.current = null; + }} + > {handleNodeType === 'chartBlock' || handleNodeType === 'tableBlock' ? null : (
diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index b028b7a43..2a6520104 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -491,6 +491,14 @@ code.dollar:before { @apply outline-2 outline-primary/40 outline-offset-2 rounded-lg; } +.story-editor .tiptap .nao-block-selected { + @apply bg-primary/10 rounded-md; +} + +.dark .story-editor .tiptap .nao-block-selected { + @apply bg-blue-500/25; +} + .story-editor .drag-handle { position: fixed; display: flex; From d4b1e61bd4da49116d0a0d40fbc1b32fe70582ec Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 21 Jul 2026 17:38:02 +0200 Subject: [PATCH 3/8] fix: Escape to cancel selection now works fine --- .../side-panel/story-block-selection.test.ts | 19 ++++++++++ .../side-panel/story-block-selection.ts | 36 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/apps/frontend/src/components/side-panel/story-block-selection.test.ts b/apps/frontend/src/components/side-panel/story-block-selection.test.ts index c31477b3e..c3d167a1d 100644 --- a/apps/frontend/src/components/side-panel/story-block-selection.test.ts +++ b/apps/frontend/src/components/side-panel/story-block-selection.test.ts @@ -123,4 +123,23 @@ describe('story block selection', () => { expect(buildBlockMoveTransaction(editor.state, [a, b], b)).toBeNull(); }); }); + + describe('clearing from outside the editor', () => { + it('clears the selection on Escape', () => { + const [first] = topLevelBlockPositions(editor.state.doc); + selectBlocks(editor, [first], first); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(getSelectedBlockPositions(editor.state)).toEqual([]); + }); + + it('clears the selection when clicking outside the editor content', () => { + const [first] = topLevelBlockPositions(editor.state.doc); + selectBlocks(editor, [first], first); + const outside = document.createElement('div'); + document.body.appendChild(outside); + outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(getSelectedBlockPositions(editor.state)).toEqual([]); + outside.remove(); + }); + }); }); diff --git a/apps/frontend/src/components/side-panel/story-block-selection.ts b/apps/frontend/src/components/side-panel/story-block-selection.ts index 7ec6fb038..d9f398424 100644 --- a/apps/frontend/src/components/side-panel/story-block-selection.ts +++ b/apps/frontend/src/components/side-panel/story-block-selection.ts @@ -141,6 +141,42 @@ function buildBlockSelectionPlugin(): Plugin { return true; }, }, + view(editorView) { + const clearSelection = () => { + const current = blockSelectionPluginKey.getState(editorView.state); + if (!current?.blocks.length) { + return; + } + editorView.dispatch(editorView.state.tr.setMeta(blockSelectionPluginKey, { blocks: [], anchor: null })); + }; + + const onMouseDown = (event: MouseEvent) => { + const target = event.target; + if (target instanceof Node && editorView.dom.contains(target)) { + return; + } + if (target instanceof HTMLElement && target.closest('.drag-handle')) { + return; + } + clearSelection(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + clearSelection(); + } + }; + + document.addEventListener('mousedown', onMouseDown, true); + document.addEventListener('keydown', onKeyDown, true); + + return { + destroy() { + document.removeEventListener('mousedown', onMouseDown, true); + document.removeEventListener('keydown', onKeyDown, true); + }, + }; + }, }); } From 0b9b5456d5a6704136c637509ed8abe303244e70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 08:15:43 +0000 Subject: [PATCH 4/8] fix: show gutter drag handle for all blocks so multi-select moves charts/tables --- .../src/components/side-panel/story-editor.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/components/side-panel/story-editor.tsx b/apps/frontend/src/components/side-panel/story-editor.tsx index c70b4f965..446603ad6 100644 --- a/apps/frontend/src/components/side-panel/story-editor.tsx +++ b/apps/frontend/src/components/side-panel/story-editor.tsx @@ -1156,7 +1156,6 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } const dragPreviewPositionsRef = useRef(null); const storyEditorRef = useRef(null); const [isBlockDragging, setIsBlockDragging] = useState(false); - const [handleNodeType, setHandleNodeType] = useState(null); const storyBlockDragContext = useMemo( () => ({ sourceRef: storyBlockSourceRef, @@ -1172,7 +1171,6 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } setIsBlockDragging(false); }, []); const handleDragHandleNodeChange = useCallback(({ node, pos }: { node: PMNode | null; pos: number }) => { - setHandleNodeType(node?.type.name ?? null); handleNodePosRef.current = node ? pos : null; }, []); onSaveRef.current = onSave; @@ -1450,11 +1448,9 @@ export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave } dragPreviewPositionsRef.current = null; }} > - {handleNodeType === 'chartBlock' || handleNodeType === 'tableBlock' ? null : ( -
- -
- )} +
+ +
)} From abe50353515470aae4ab9dc594a0db2c6583f669 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 22 Jul 2026 11:48:48 +0200 Subject: [PATCH 5/8] feat: Improve auto-scroll when moving elements in story editing. Additionnally fixed a color bug when moving charts --- .../components/side-panel/story-viewer.tsx | 2 ++ .../src/components/story-page-body.tsx | 7 +++- .../components/tool-calls/display-chart.tsx | 5 ++- apps/shared/src/chart-builder.tsx | 8 +++-- apps/shared/tests/chart-builder.test.tsx | 33 +++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/components/side-panel/story-viewer.tsx b/apps/frontend/src/components/side-panel/story-viewer.tsx index dbbf18f9c..f1114a133 100644 --- a/apps/frontend/src/components/side-panel/story-viewer.tsx +++ b/apps/frontend/src/components/side-panel/story-viewer.tsx @@ -22,6 +22,7 @@ import type { Editor as TiptapEditor } from '@tiptap/react'; import type { StoryCodeViewHandle } from './story-code-view'; import { AssetAnalyticsDialog } from '@/components/asset-analytics-dialog'; import { useSidePanel } from '@/contexts/side-panel'; +import { useDragAutoScroll } from '@/hooks/use-drag-auto-scroll'; import { useTrackViewDuration } from '@/hooks/use-track-view-duration'; import { ReadonlyAgentMessagesProvider, useOptionalAgentContext } from '@/contexts/agent.provider'; import { StoryChartEditProvider } from '@/contexts/story-chart-edit'; @@ -155,6 +156,7 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: code: storyCode, viewMode, }); + useDragAutoScroll(scrollContainerRef); if (!storyCode) { if (chatQuery.isLoading) { diff --git a/apps/frontend/src/components/story-page-body.tsx b/apps/frontend/src/components/story-page-body.tsx index 416a28fae..3d99ca6fa 100644 --- a/apps/frontend/src/components/story-page-body.tsx +++ b/apps/frontend/src/components/story-page-body.tsx @@ -1,7 +1,9 @@ +import { useRef } from 'react'; import type { ReactNode } from 'react'; import type { QueryDataMap } from '@/components/story-embeds'; import type { useStoryPageEditor } from '@/hooks/use-story-page-editor'; +import { useDragAutoScroll } from '@/hooks/use-drag-auto-scroll'; import { StoryCodeView } from '@/components/side-panel/story-code-view'; import { StoryEditor } from '@/components/side-panel/story-editor'; import { StoryEmbedDataProvider } from '@/contexts/story-embed-data'; @@ -14,10 +16,13 @@ interface StoryPageBodyProps { } export function StoryPageBody({ code, editor, preview, queryData }: StoryPageBodyProps) { + const scrollContainerRef = useRef(null); + useDragAutoScroll(scrollContainerRef); + if (editor.viewMode === 'edit') { return ( -
+
diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 8523aad32..3234e04bd 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -2,7 +2,7 @@ import { buildChart, bucketPieData, buildStoryChartBlock, labelize } from '@nao/ import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Code, Download, FilePlus, Pencil } from 'lucide-react'; -import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useId, useMemo, useRef, useState } from 'react'; import { useOptionalAgentContext } from '../../contexts/agent.provider'; import GraphLoaderAnimated from '../icons/graph-loader-animated'; @@ -340,6 +340,7 @@ export const ChartDisplay = memo(function ChartDisplay({ normalSize = false, }: ChartDisplayProps) { const dateFormat = useDateFormat(); + const gradientIdPrefix = `${useId().replace(/:/g, '')}-`; const containerRef = useRef(null); const [width, setWidth] = useState(0); useResizeObserver(containerRef, (element) => { @@ -465,6 +466,7 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisMaxLabelChars, showGrid, showDataLabels, + gradientIdPrefix, margin: { top: 0, right: 0, bottom: 0, left: 0 }, yAxisMin, yAxisMax, @@ -516,6 +518,7 @@ export const ChartDisplay = memo(function ChartDisplay({ yAxisMin, yAxisMax, showDataLabels, + gradientIdPrefix, legendPayload, handleToggleSeriesVisibility, title, diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index 265a206df..80b442b78 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -168,6 +168,8 @@ export interface BuildChartProps { yAxisMax?: number; /** Chart background color, used as the separator between stacked segments. Pass a concrete color on surfaces where CSS vars do not resolve (backend PNG/HTML export). */ backgroundColor?: string; + /** Prefix for SVG gradient ids so multiple charts on one page (and drag clones) don't collide. */ + gradientIdPrefix?: string; showDataLabels?: boolean; } @@ -580,6 +582,8 @@ function buildAreaChart(props: ResolvedProps) { yAxisMax, showDataLabels, } = props; + const gradientIdPrefix = props.gradientIdPrefix ?? ''; + const gradientIdFor = (index: number) => `${gradientIdPrefix}grad-${index}`; const isStacked = displayChart.isStackedChartType(chartType); const isPercent = displayChart.isPercentStackedChartType(chartType); const zeroBaseline = chartType !== 'line'; @@ -593,7 +597,7 @@ function buildAreaChart(props: ResolvedProps) { {renderedSeries.map((s, i) => { const color = colorFor(s.data_key, i); - const gradientId = `grad-${i}`; + const gradientId = gradientIdFor(i); return ( @@ -632,7 +636,7 @@ function buildAreaChart(props: ResolvedProps) { dataKey={s.data_key} type='monotone' stroke={colorFor(s.data_key, i)} - fill={`url(#grad-${i})`} + fill={`url(#${gradientIdFor(i)})`} stackId={isStacked ? 'stack' : undefined} isAnimationActive={false} > diff --git a/apps/shared/tests/chart-builder.test.tsx b/apps/shared/tests/chart-builder.test.tsx index 12d186218..426d901a4 100644 --- a/apps/shared/tests/chart-builder.test.tsx +++ b/apps/shared/tests/chart-builder.test.tsx @@ -64,6 +64,30 @@ describe('buildChart', () => { expect(yAxis?.props.domain).toEqual([0, 60]); }); + it('uses the chart-specific prefix for area gradient ids and fills', () => { + const firstChart = buildChart({ + data: [{ name: 'A', value: 10 }], + chartType: 'area', + xAxisKey: 'name', + xAxisType: 'category', + series: [{ data_key: 'value' }], + gradientIdPrefix: 'a-', + }); + const secondChart = buildChart({ + data: [{ name: 'A', value: 10 }], + chartType: 'area', + xAxisKey: 'name', + xAxisType: 'category', + series: [{ data_key: 'value' }], + gradientIdPrefix: 'b-', + }); + + expect(getGradient(firstChart)?.props.id).toBe('a-grad-0'); + expect(getArea(firstChart)?.props.fill).toBe('url(#a-grad-0)'); + expect(getGradient(secondChart)?.props.id).toBe('b-grad-0'); + expect(getGradient(firstChart)?.props.id).not.toBe(getGradient(secondChart)?.props.id); + }); + it('shows and angles every compact category label with a custom tick font size', () => { const xAxis = getXAxis( buildChart({ @@ -143,6 +167,15 @@ function getXAxis(chart: ReactElement): ReactElement | undefined { return flattenChildren(chart.props.children).find((child) => child.type.displayName === 'XAxis'); } +function getGradient(chart: ReactElement): ReactElement | undefined { + const definitions = flattenChildren(chart.props.children).find((child) => child.type === 'defs'); + return flattenChildren(definitions?.props.children).find((child) => child.type === 'linearGradient'); +} + +function getArea(chart: ReactElement): ReactElement | undefined { + return flattenChildren(chart.props.children).find((child) => child.type.displayName === 'Area'); +} + function flattenChildren(children: unknown): ReactElement[] { if (Array.isArray(children)) { return children.flatMap(flattenChildren); From 6c395411d3413883210e50d2cf54514baec57fbe Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 22 Jul 2026 11:50:22 +0200 Subject: [PATCH 6/8] forgot to add this file. Adding now --- .../src/hooks/use-drag-auto-scroll.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/frontend/src/hooks/use-drag-auto-scroll.ts diff --git a/apps/frontend/src/hooks/use-drag-auto-scroll.ts b/apps/frontend/src/hooks/use-drag-auto-scroll.ts new file mode 100644 index 000000000..e4b9ef741 --- /dev/null +++ b/apps/frontend/src/hooks/use-drag-auto-scroll.ts @@ -0,0 +1,73 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; + +const HOT_ZONE = 80; +const MAX_SPEED = 22; + +export function useDragAutoScroll(scrollContainerRef: RefObject): void { + useEffect(() => { + let velocity = 0; + let frame: number | null = null; + + const step = () => { + if (scrollContainerRef.current && velocity !== 0) { + scrollContainerRef.current.scrollTop += velocity; + frame = requestAnimationFrame(step); + } else { + frame = null; + } + }; + + const handleDragOver = (event: DragEvent) => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + velocity = computeAutoScrollVelocity(container.getBoundingClientRect(), event.clientX, event.clientY); + if (velocity !== 0 && frame === null) { + frame = requestAnimationFrame(step); + } + }; + + const stop = () => { + velocity = 0; + if (frame !== null) { + cancelAnimationFrame(frame); + frame = null; + } + }; + + document.addEventListener('dragover', handleDragOver); + document.addEventListener('drop', stop); + document.addEventListener('dragend', stop); + + return () => { + document.removeEventListener('dragover', handleDragOver); + document.removeEventListener('drop', stop); + document.removeEventListener('dragend', stop); + stop(); + }; + }, [scrollContainerRef]); +} + +export function computeAutoScrollVelocity(rect: DOMRect, clientX: number, clientY: number): number { + if (clientX < rect.left || clientX > rect.right) { + return 0; + } + + const distanceFromTop = clientY - rect.top; + const distanceFromBottom = rect.bottom - clientY; + + if (distanceFromTop < HOT_ZONE) { + return -MAX_SPEED * clamp01((HOT_ZONE - distanceFromTop) / HOT_ZONE); + } + if (distanceFromBottom < HOT_ZONE) { + return MAX_SPEED * clamp01((HOT_ZONE - distanceFromBottom) / HOT_ZONE); + } + return 0; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} From 5b617cfd416715fdc442d7cca531dd4181f06383 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 23 Jul 2026 13:52:18 +0200 Subject: [PATCH 7/8] Move test to test folder --- .../side-panel/story-block-selection.test.ts | 145 ------------------ 1 file changed, 145 deletions(-) delete mode 100644 apps/frontend/src/components/side-panel/story-block-selection.test.ts diff --git a/apps/frontend/src/components/side-panel/story-block-selection.test.ts b/apps/frontend/src/components/side-panel/story-block-selection.test.ts deleted file mode 100644 index c3d167a1d..000000000 --- a/apps/frontend/src/components/side-panel/story-block-selection.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -// @vitest-environment jsdom - -import { Editor } from '@tiptap/core'; -import StarterKit from '@tiptap/starter-kit'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - BlockSelection, - blockSelectionPluginKey, - buildBlockMoveTransaction, - getSelectedBlockPositions, - isDropInsideSelection, - rangeBetween, - topLevelBlockPositions, -} from './story-block-selection'; - -function createEditor(): Editor { - return new Editor({ - extensions: [StarterKit, BlockSelection], - content: '

AA

BB

CC

', - }); -} - -function selectBlocks(editor: Editor, blocks: number[], anchor: number | null): void { - editor.view.dispatch(editor.state.tr.setMeta(blockSelectionPluginKey, { blocks, anchor })); -} - -describe('story block selection', () => { - let editor: Editor; - - beforeEach(() => { - editor = createEditor(); - }); - - afterEach(() => { - editor.destroy(); - }); - - it('lists every top-level block position', () => { - expect(topLevelBlockPositions(editor.state.doc)).toHaveLength(3); - }); - - it('builds an inclusive range between two blocks regardless of order', () => { - const [first, second, third] = topLevelBlockPositions(editor.state.doc); - expect(rangeBetween(editor.state.doc, first, third)).toEqual([first, second, third]); - expect(rangeBetween(editor.state.doc, third, first)).toEqual([first, second, third]); - expect(rangeBetween(editor.state.doc, second, second)).toEqual([second]); - }); - - it('starts with no selection', () => { - expect(getSelectedBlockPositions(editor.state)).toEqual([]); - }); - - it('stores and clears the selected blocks', () => { - const [first, , third] = topLevelBlockPositions(editor.state.doc); - selectBlocks(editor, [first, third], first); - expect(getSelectedBlockPositions(editor.state)).toEqual([first, third]); - - selectBlocks(editor, [], null); - expect(getSelectedBlockPositions(editor.state)).toEqual([]); - }); - - it('keeps the selection when an unrelated block is edited', () => { - const [first] = topLevelBlockPositions(editor.state.doc); - selectBlocks(editor, [first], first); - editor.view.dispatch(editor.state.tr.insertText(' x', editor.state.doc.content.size - 1)); - expect(getSelectedBlockPositions(editor.state)).toEqual([first]); - }); - - it('drops a selected block once it is deleted', () => { - const [, second, third] = topLevelBlockPositions(editor.state.doc); - selectBlocks(editor, [second, third], second); - editor.view.dispatch(editor.state.tr.delete(third, editor.state.doc.content.size)); - expect(getSelectedBlockPositions(editor.state)).toEqual([second]); - }); - - describe('isDropInsideSelection', () => { - it('is true when dropping between two adjacent selected blocks', () => { - const [first, second] = topLevelBlockPositions(editor.state.doc); - expect(isDropInsideSelection(editor.state.doc, second, [first, second])).toBe(true); - }); - - it('is false when dropping into the gap around an unselected middle block', () => { - const [first, second, third] = topLevelBlockPositions(editor.state.doc); - expect(isDropInsideSelection(editor.state.doc, second, [first, third])).toBe(false); - expect(isDropInsideSelection(editor.state.doc, third, [first, third])).toBe(false); - }); - - it('is false when dropping outside the selection', () => { - const [first, , third] = topLevelBlockPositions(editor.state.doc); - expect(isDropInsideSelection(editor.state.doc, first, [first, third])).toBe(false); - expect(isDropInsideSelection(editor.state.doc, editor.state.doc.content.size, [first, third])).toBe(false); - }); - }); - - describe('buildBlockMoveTransaction', () => { - it('moves a non-contiguous selection around an unselected middle block', () => { - const [a, , c] = topLevelBlockPositions(editor.state.doc); - const move = buildBlockMoveTransaction(editor.state, [a, c], c); - expect(move).not.toBeNull(); - if (!move) { - return; - } - editor.view.dispatch(move.transaction); - expect(editor.state.doc.childCount).toBe(3); - expect(editor.state.doc.textContent).toBe('BBAACC'); - }); - - it('moves a contiguous selection past a later block without dropping blocks', () => { - const [a, b, c] = topLevelBlockPositions(editor.state.doc); - const move = buildBlockMoveTransaction(editor.state, [a, b], c); - expect(move).not.toBeNull(); - if (!move) { - return; - } - editor.view.dispatch(move.transaction); - expect(editor.state.doc.childCount).toBe(3); - expect(editor.state.doc.textContent).toBe('AABBCC'); - }); - - it('returns null when dropping between two adjacent selected blocks', () => { - const [a, b] = topLevelBlockPositions(editor.state.doc); - expect(buildBlockMoveTransaction(editor.state, [a, b], b)).toBeNull(); - }); - }); - - describe('clearing from outside the editor', () => { - it('clears the selection on Escape', () => { - const [first] = topLevelBlockPositions(editor.state.doc); - selectBlocks(editor, [first], first); - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - expect(getSelectedBlockPositions(editor.state)).toEqual([]); - }); - - it('clears the selection when clicking outside the editor content', () => { - const [first] = topLevelBlockPositions(editor.state.doc); - selectBlocks(editor, [first], first); - const outside = document.createElement('div'); - document.body.appendChild(outside); - outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); - expect(getSelectedBlockPositions(editor.state)).toEqual([]); - outside.remove(); - }); - }); -}); From a973cb9b19e7caea22f403774c6b3dca0b6fe7f4 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 23 Jul 2026 14:16:08 +0200 Subject: [PATCH 8/8] Prettier cleanup --- .../src/components/side-panel/hooks/use-story-editor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts b/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts index 3c811ec0e..51fdef9c6 100644 --- a/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts +++ b/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts @@ -321,7 +321,9 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams 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.view.dispatch( + editor.state.tr.setMeta(blockSelectionPluginKey, { blocks: [], anchor: null }), + ); } } },