diff --git a/apps/backend/src/agents/tools/story.ts b/apps/backend/src/agents/tools/story.ts index 7ad4a6c15..c9d2ce39b 100644 --- a/apps/backend/src/agents/tools/story.ts +++ b/apps/backend/src/agents/tools/story.ts @@ -16,7 +16,7 @@ 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 place 2–4 charts/tables side by side; its direct /
blocks are the columns.', + '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.', 'Use consecutive ... blocks to organize a story into top-level tabs.', 'Default to a single flowing story. Use tabs only when the user asks for tabs, or when the content splits into clearly distinct sections that are better separated than stacked (e.g. overview vs. detail, one topic/department/metric per tab). Avoid tabs for a short or single-topic story. Always follow the user\'s explicit request (e.g. "a tab per chart" means one chart per tab). When using tabs, the entire story must consist of ... blocks — no content outside a tab.', 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 67e83c944..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 @@ -4,9 +4,20 @@ import { Fragment, Slice } from '@tiptap/pm/model'; import { dropPoint } from '@tiptap/pm/transform'; import { useEditor } from '@tiptap/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + blockSelectionPluginKey, + buildBlockMoveTransaction, + getSelectedBlockPositions, +} from '../story-block-selection'; import { EDITOR_EXTENSIONS } from '../story-editor-extensions'; import { GRID_COLUMN_DRAG_TYPE, STORY_BLOCK_DRAG_TYPE } from '../story-editor-drag-context'; -import { createBlockNode, preprocessForEditor, removeCardFromOrigin } from '../story-editor-utils'; +import { + cloneElementWithStyles, + createBlockNode, + dispatchDropWithScroll, + preprocessForEditor, + removeCardFromOrigin, +} from '../story-editor-utils'; import type { GridDragSource, StoryBlockDragSource } from '../story-editor-drag-context'; import type { Node as PMNode } from '@tiptap/pm/model'; import type { Editor } from '@tiptap/react'; @@ -28,6 +39,10 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams 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( @@ -41,10 +56,12 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams const resetDragContexts = useCallback(() => { gridDragSourceRef.current = null; storyBlockSourceRef.current = null; + multiBlockDragRef.current = null; setIsBlockDragging(false); }, []); - const handleDragHandleNodeChange = useCallback(({ node }: { node: PMNode | null }) => { + const handleDragHandleNodeChange = useCallback(({ node, pos }: { node: PMNode | null; pos: number }) => { setHandleNodeType(node?.type.name ?? null); + handleNodePosRef.current = node ? pos : null; }, []); onSaveRef.current = onSave; @@ -84,6 +101,34 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams }, handleDrop(view, event) { const dataTransfer = event.dataTransfer; + if (multiBlockDragRef.current && multiBlockDragRef.current.length > 1) { + try { + const positions = multiBlockDragRef.current; + const { state } = view; + const nodes = positions + .map((position) => state.doc.nodeAt(position)) + .filter((node): node is PMNode => node != null); + if (nodes.length === 0) { + return true; + } + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!coords) { + return true; + } + const slice = new Slice(Fragment.fromArray(nodes), 0, 0); + const insertPos = dropPoint(state.doc, coords.pos, slice) ?? coords.pos; + const move = buildBlockMoveTransaction(state, positions, insertPos); + if (!move) { + return true; + } + dispatchDropWithScroll(view, move.transaction, move.insertPos); + event.preventDefault(); + return true; + } finally { + resetDragContexts(); + } + } + if (dataTransfer?.types.includes(GRID_COLUMN_DRAG_TYPE)) { try { const source = gridDragSourceRef.current; @@ -185,6 +230,61 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams }, }); + useEffect(() => { + const container = storyEditorRef.current; + if (!container || !editor) { + return; + } + + const onDragStart = (event: DragEvent) => { + const positions = dragPreviewPositionsRef.current; + if (!positions || positions.length === 0 || !event.dataTransfer) { + return; + } + + const nodes = positions + .map((position) => editor.view.nodeDOM(position)) + .filter((dom): dom is HTMLElement => dom instanceof HTMLElement); + if (nodes.length === 0) { + return; + } + + const preview = document.createElement('div'); + preview.style.position = 'absolute'; + preview.style.top = '-10000px'; + preview.style.left = '-10000px'; + + for (const dom of nodes) { + preview.appendChild(cloneElementWithStyles(dom)); + } + + document.body.appendChild(preview); + event.dataTransfer.setDragImage(preview, 16, 16); + + const cleanup = () => { + preview.remove(); + document.removeEventListener('dragend', cleanup); + document.removeEventListener('drop', cleanup); + }; + document.addEventListener('dragend', cleanup); + document.addEventListener('drop', cleanup); + }; + + const clearDropCursor = () => { + dragPreviewPositionsRef.current = null; + editor.view.dom.dispatchEvent(new DragEvent('dragleave')); + }; + + container.addEventListener('dragstart', onDragStart); + document.addEventListener('dragend', clearDropCursor, true); + document.addEventListener('drop', clearDropCursor, true); + return () => { + container.removeEventListener('dragstart', onDragStart); + document.removeEventListener('dragend', clearDropCursor, true); + document.removeEventListener('drop', clearDropCursor, true); + }; + }, [editor]); + useEffect(() => { editorRef.current = editor; return () => { @@ -202,5 +302,46 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams editor.commands.setContent(processedContent, { emitUpdate: false, contentType: 'markdown' }); }, [editor, code, processedContent]); - return { editor, gridDragSourceRef, storyBlockDragContext, handleDragHandleNodeChange, handleNodeType }; + const onElementDragStart = useCallback( + (event: DragEvent) => { + if (!editor) { + return; + } + const selected = getSelectedBlockPositions(editor.state); + const hoveredPosition = handleNodePosRef.current; + const isMulti = selected.length > 1 && hoveredPosition != null && selected.includes(hoveredPosition); + if (isMulti) { + const sorted = [...selected].sort((first, second) => first - second); + multiBlockDragRef.current = sorted; + dragPreviewPositionsRef.current = sorted; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + } + } else { + multiBlockDragRef.current = null; + dragPreviewPositionsRef.current = hoveredPosition != null ? [hoveredPosition] : null; + if (selected.length > 0) { + editor.view.dispatch( + editor.state.tr.setMeta(blockSelectionPluginKey, { blocks: [], anchor: null }), + ); + } + } + }, + [editor], + ); + const onElementDragEnd = useCallback(() => { + multiBlockDragRef.current = null; + dragPreviewPositionsRef.current = null; + }, []); + + return { + editor, + gridDragSourceRef, + storyBlockDragContext, + handleDragHandleNodeChange, + handleNodeType, + storyEditorRef, + onElementDragStart, + onElementDragEnd, + }; } 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..d9f398424 --- /dev/null +++ b/apps/frontend/src/components/side-panel/story-block-selection.ts @@ -0,0 +1,263 @@ +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; + }, + }, + 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); + }, + }; + }, + }); +} + +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-extensions.ts b/apps/frontend/src/components/side-panel/story-editor-extensions.ts index 1801134a0..70d11820a 100644 --- a/apps/frontend/src/components/side-panel/story-editor-extensions.ts +++ b/apps/frontend/src/components/side-panel/story-editor-extensions.ts @@ -2,6 +2,7 @@ import { Extension } from '@tiptap/core'; import { TableKit } from '@tiptap/extension-table'; import { Markdown } from '@tiptap/markdown'; import StarterKit from '@tiptap/starter-kit'; +import { BlockSelection } from './story-block-selection'; import { ChartBlock } from './story-editor-chart-block'; import { GridBlock } from './story-editor-grid-block'; import { TableBlock } from './story-editor-table-block'; @@ -88,4 +89,5 @@ export const EDITOR_EXTENSIONS = [ ChartBlock, TableBlock, GridBlock, + BlockSelection, ]; diff --git a/apps/frontend/src/components/side-panel/story-editor-utils.ts b/apps/frontend/src/components/side-panel/story-editor-utils.ts index 8f71687cc..b7f5acd27 100644 --- a/apps/frontend/src/components/side-panel/story-editor-utils.ts +++ b/apps/frontend/src/components/side-panel/story-editor-utils.ts @@ -1,6 +1,8 @@ import { popGridColumn, TAG_ATTRS } from '@nao/shared/story-segments'; +import { Selection } from '@tiptap/pm/state'; 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 { CardOrigin } from './story-editor-drag-context'; function encodeForAttr(str: string): string { @@ -81,3 +83,34 @@ export function removeCardFromOrigin(transaction: Transaction, state: EditorStat remainingNode, ); } + +export 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; +} + +export 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' }); + }); +} diff --git a/apps/frontend/src/components/side-panel/story-editor.tsx b/apps/frontend/src/components/side-panel/story-editor.tsx index f32485bc1..4aea433ac 100644 --- a/apps/frontend/src/components/side-panel/story-editor.tsx +++ b/apps/frontend/src/components/side-panel/story-editor.tsx @@ -16,15 +16,29 @@ interface StoryEditorProps { } export const StoryEditor = memo(function StoryEditor({ code, editorRef, onSave }: StoryEditorProps) { - const { editor, gridDragSourceRef, storyBlockDragContext, handleDragHandleNodeChange, handleNodeType } = - useStoryEditor({ code, editorRef, onSave }); + const { + editor, + gridDragSourceRef, + storyBlockDragContext, + handleDragHandleNodeChange, + handleNodeType, + storyEditorRef, + onElementDragStart, + onElementDragEnd, + } = useStoryEditor({ code, editorRef, onSave }); return ( -
+
{editor && ( - + {handleNodeType === 'chartBlock' || handleNodeType === 'tableBlock' ? null : (
diff --git a/apps/frontend/src/components/side-panel/story-viewer.tsx b/apps/frontend/src/components/side-panel/story-viewer.tsx index 9cb5e5696..efa9c30d2 100644 --- a/apps/frontend/src/components/side-panel/story-viewer.tsx +++ b/apps/frontend/src/components/side-panel/story-viewer.tsx @@ -25,6 +25,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'; @@ -189,6 +190,7 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp, i 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 56f9a3125..7ac8746a4 100644 --- a/apps/frontend/src/components/story-page-body.tsx +++ b/apps/frontend/src/components/story-page-body.tsx @@ -1,8 +1,10 @@ +import { useRef } from 'react'; import { parseStoryTabs } from '@nao/shared/story-tabs'; 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 { StoryTabbedEditor } from '@/components/side-panel/story-tabbed-editor'; @@ -16,12 +18,15 @@ interface StoryPageBodyProps { } export function StoryPageBody({ code, editor, preview, queryData }: StoryPageBodyProps) { + const scrollContainerRef = useRef(null); + useDragAutoScroll(scrollContainerRef); + if (editor.viewMode === 'edit') { const tabs = parseStoryTabs(code); const isTabbed = Boolean(tabs?.length); return ( -
+
{isTabbed ? ( (null); const [width, setWidth] = useState(0); useResizeObserver(containerRef, (element) => { @@ -539,6 +540,7 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisMaxLabelChars, showGrid, showDataLabels, + gradientIdPrefix, margin: { top: 0, right: 0, bottom: 0, left: 0 }, yAxisMin, yAxisMax, @@ -594,6 +596,7 @@ export const ChartDisplay = memo(function ChartDisplay({ yAxisMin, yAxisMax, showDataLabels, + gradientIdPrefix, hideTotal, legendPayload, handleToggleSeriesVisibility, 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)); +} diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 26b68b545..fed403d03 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -492,6 +492,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; diff --git a/apps/frontend/tests/story-block-selection.test.ts b/apps/frontend/tests/story-block-selection.test.ts new file mode 100644 index 000000000..0ffc32e8c --- /dev/null +++ b/apps/frontend/tests/story-block-selection.test.ts @@ -0,0 +1,145 @@ +// @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 '@/components/side-panel/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(); + }); + }); +}); 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);