From b8e86bb46f3c12d485bd8711171a90de00cfe543 Mon Sep 17 00:00:00 2001 From: Aleksandar Petrov <8142643+aleks-p@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:00:23 -0400 Subject: [PATCH] Associate sticky notes with frames --- .../components/ExploreMapStickyNote.tsx | 113 ++++++++++++++- public/app/features/explore-map/crdt/state.ts | 133 ++++++++++++++++++ public/app/features/explore-map/crdt/types.ts | 35 +++++ .../features/explore-map/state/crdtSlice.ts | 73 +++++++++- .../features/explore-map/state/selectors.ts | 66 ++++++++- 5 files changed, 412 insertions(+), 8 deletions(-) diff --git a/public/app/features/explore-map/components/ExploreMapStickyNote.tsx b/public/app/features/explore-map/components/ExploreMapStickyNote.tsx index 08ab76b0b98..71d8705e3b6 100644 --- a/public/app/features/explore-map/components/ExploreMapStickyNote.tsx +++ b/public/app/features/explore-map/components/ExploreMapStickyNote.tsx @@ -5,16 +5,19 @@ import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Button, TextArea, useStyles2 } from '@grafana/ui'; -import { useDispatch } from 'app/types/store'; +import { useDispatch, useSelector } from 'app/types/store'; import { + associatePostItWithFrame, bringPostItNoteToFront, + disassociatePostItFromFrame, removePostItNote, updatePostItNoteColor, updatePostItNotePosition, updatePostItNoteSize, updatePostItNoteText, } from '../state/crdtSlice'; +import { selectFrames } from '../state/selectors'; interface ExploreMapStickyNoteProps { postIt: { @@ -23,6 +26,9 @@ interface ExploreMapStickyNoteProps { text: string; color: string; createdBy?: string; + frameId?: string; + frameOffsetX?: number; + frameOffsetY?: number; }; zoom: number; } @@ -44,8 +50,41 @@ export function ExploreMapStickyNote({ postIt, zoom }: ExploreMapStickyNoteProps const [editText, setEditText] = useState(postIt.text); const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null); + const frames = useSelector((state) => selectFrames(state.exploreMapCRDT)); + const activeFrameDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeFrameDrag); + const colorInfo = STICKY_NOTE_COLORS.find((c) => c.name === postIt.color) || STICKY_NOTE_COLORS.find((c) => c.name === 'blue') || STICKY_NOTE_COLORS[0]; + // Calculate effective position considering frame drag + let effectiveX = postIt.position.x; + let effectiveY = postIt.position.y; + + // If this sticky note's frame is being dragged, apply the frame drag offset + if (activeFrameDragInfo && postIt.frameId === activeFrameDragInfo.draggedFrameId) { + effectiveX += activeFrameDragInfo.deltaX; + effectiveY += activeFrameDragInfo.deltaY; + } + + // Check if sticky note intersects with a frame (>50% overlap) + const checkFrameIntersection = useCallback( + (x: number, y: number, width: number, height: number): string | null => { + const postItArea = width * height; + + for (const frame of Object.values(frames)) { + const intersectX = Math.max(0, Math.min(x + width, frame.position.x + frame.position.width) - Math.max(x, frame.position.x)); + const intersectY = Math.max(0, Math.min(y + height, frame.position.y + frame.position.height) - Math.max(y, frame.position.y)); + const intersectArea = intersectX * intersectY; + + if (intersectArea > postItArea * 0.5) { + return frame.id; + } + } + + return null; + }, + [frames] + ); + const handleDragStart: RndDragCallback = useCallback( (_e, data) => { setDragStartPos({ x: data.x, y: data.y }); @@ -71,11 +110,42 @@ export function ExploreMapStickyNote({ postIt, zoom }: ExploreMapStickyNoteProps y: data.y, }) ); + + // Check if sticky note should be associated with a frame + const intersectingFrameId = checkFrameIntersection( + data.x, + data.y, + postIt.position.width, + postIt.position.height + ); + + if (intersectingFrameId && intersectingFrameId !== postIt.frameId) { + // Sticky note moved into a frame + const frame = frames[intersectingFrameId]; + const offsetX = data.x - frame.position.x; + const offsetY = data.y - frame.position.y; + + dispatch( + associatePostItWithFrame({ + postItId: postIt.id, + frameId: intersectingFrameId, + offsetX, + offsetY, + }) + ); + } else if (!intersectingFrameId && postIt.frameId) { + // Sticky note moved out of frame + dispatch( + disassociatePostItFromFrame({ + postItId: postIt.id, + }) + ); + } } } setDragStartPos(null); }, - [dispatch, postIt.id, dragStartPos] + [dispatch, postIt.id, postIt.position.width, postIt.position.height, postIt.frameId, dragStartPos, checkFrameIntersection, frames] ); const handleResizeStop: RndResizeCallback = useCallback( @@ -100,8 +170,39 @@ export function ExploreMapStickyNote({ postIt, zoom }: ExploreMapStickyNoteProps height: newHeight, }) ); + + // Check if sticky note should be associated with a frame after resize + const intersectingFrameId = checkFrameIntersection( + position.x, + position.y, + newWidth, + newHeight + ); + + if (intersectingFrameId && intersectingFrameId !== postIt.frameId) { + // Sticky note resized into a frame + const frame = frames[intersectingFrameId]; + const offsetX = position.x - frame.position.x; + const offsetY = position.y - frame.position.y; + + dispatch( + associatePostItWithFrame({ + postItId: postIt.id, + frameId: intersectingFrameId, + offsetX, + offsetY, + }) + ); + } else if (!intersectingFrameId && postIt.frameId) { + // Sticky note resized out of frame + dispatch( + disassociatePostItFromFrame({ + postItId: postIt.id, + }) + ); + } }, - [dispatch, postIt.id] + [dispatch, postIt.id, postIt.frameId, checkFrameIntersection, frames] ); const handleDoubleClick = useCallback(() => { @@ -162,15 +263,19 @@ export function ExploreMapStickyNote({ postIt, zoom }: ExploreMapStickyNoteProps [dispatch, postIt.id] ); + // Disable dragging when the frame is being dragged + const isDraggingDisabled = !!(activeFrameDragInfo && postIt.frameId === activeFrameDragInfo.draggedFrameId); + return ( { const postItNotes: Record = {}; for (const postItId of this.getPostItNoteIds()) { const postIt = this.getPostItNoteForUI(postItId); @@ -1000,6 +1075,10 @@ export class CRDTStateManager { return this.applyUpdatePostItText(operation); case 'update-postit-color': return this.applyUpdatePostItColor(operation); + case 'associate-postit-with-frame': + return this.applyAssociatePostItWithFrame(operation); + case 'disassociate-postit-from-frame': + return this.applyDisassociatePostItFromFrame(operation); case 'batch': return this.applyBatchOperation(operation); default: { @@ -1234,6 +1313,9 @@ export class CRDTStateManager { text: new LWWRegister(text || '', operation.timestamp), color: new LWWRegister(color || 'purple', operation.timestamp), createdBy: new LWWRegister(createdBy, operation.timestamp), + frameId: new LWWRegister(undefined, operation.timestamp), + frameOffsetX: new LWWRegister(undefined, operation.timestamp), + frameOffsetY: new LWWRegister(undefined, operation.timestamp), }); } @@ -1344,6 +1426,45 @@ export class CRDTStateManager { }; } + private applyAssociatePostItWithFrame(operation: AssociatePostItWithFrameOperation): OperationResult { + const { postItId, frameId, offsetX, offsetY } = operation.payload; + + const postItData = this.state.postItNoteData.get(postItId); + + if (!postItData) { + return { success: true, applied: false, error: 'Post-it note not found' }; + } + + // Update frame association + const frameIdUpdated = postItData.frameId.set(frameId, operation.timestamp); + const offsetXUpdated = postItData.frameOffsetX.set(offsetX, operation.timestamp); + const offsetYUpdated = postItData.frameOffsetY.set(offsetY, operation.timestamp); + + return { + success: true, + applied: frameIdUpdated || offsetXUpdated || offsetYUpdated, + }; + } + + private applyDisassociatePostItFromFrame(operation: DisassociatePostItFromFrameOperation): OperationResult { + const { postItId } = operation.payload; + const postItData = this.state.postItNoteData.get(postItId); + + if (!postItData) { + return { success: true, applied: false, error: 'Post-it note not found' }; + } + + // Clear frame association + const frameIdUpdated = postItData.frameId.set(undefined, operation.timestamp); + const offsetXUpdated = postItData.frameOffsetX.set(undefined, operation.timestamp); + const offsetYUpdated = postItData.frameOffsetY.set(undefined, operation.timestamp); + + return { + success: true, + applied: frameIdUpdated || offsetXUpdated || offsetYUpdated, + }; + } + private applyBatchOperation(operation: BatchOperation): OperationResult { let anyApplied = false; const errors: string[] = []; @@ -1553,6 +1674,9 @@ export class CRDTStateManager { text: otherPostItData.text.clone(), color: otherPostItData.color.clone(), createdBy: otherPostItData.createdBy.clone(), + frameId: otherPostItData.frameId.clone(), + frameOffsetX: otherPostItData.frameOffsetX.clone(), + frameOffsetY: otherPostItData.frameOffsetY.clone(), }); } else { // Merge each LWW register @@ -1564,6 +1688,9 @@ export class CRDTStateManager { myPostItData.text.merge(otherPostItData.text); myPostItData.color.merge(otherPostItData.color); myPostItData.createdBy.merge(otherPostItData.createdBy); + myPostItData.frameId.merge(otherPostItData.frameId); + myPostItData.frameOffsetX.merge(otherPostItData.frameOffsetX); + myPostItData.frameOffsetY.merge(otherPostItData.frameOffsetY); } } @@ -1717,6 +1844,9 @@ export class CRDTStateManager { text: data.text.toJSON(), color: data.color.toJSON(), createdBy: data.createdBy.toJSON(), + frameId: data.frameId.toJSON(), + frameOffsetX: data.frameOffsetX.toJSON(), + frameOffsetY: data.frameOffsetY.toJSON(), }; } } @@ -1766,6 +1896,9 @@ export class CRDTStateManager { text: LWWRegister.fromJSON(data.text), color: LWWRegister.fromJSON(data.color), createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp), + frameId: (data as any).frameId ? LWWRegister.fromJSON((data as any).frameId) : new LWWRegister(undefined, defaultTimestamp), + frameOffsetX: (data as any).frameOffsetX ? LWWRegister.fromJSON((data as any).frameOffsetX) : new LWWRegister(undefined, defaultTimestamp), + frameOffsetY: (data as any).frameOffsetY ? LWWRegister.fromJSON((data as any).frameOffsetY) : new LWWRegister(undefined, defaultTimestamp), }); } } diff --git a/public/app/features/explore-map/crdt/types.ts b/public/app/features/explore-map/crdt/types.ts index 1a98fbda13b..7ac4efcfecb 100644 --- a/public/app/features/explore-map/crdt/types.ts +++ b/public/app/features/explore-map/crdt/types.ts @@ -101,6 +101,11 @@ export interface CRDTPostItNoteData { // Creator metadata createdBy: LWWRegister; + + // Frame association properties + frameId: LWWRegister; + frameOffsetX: LWWRegister; + frameOffsetY: LWWRegister; } export interface CRDTExploreMapState { @@ -182,6 +187,9 @@ export interface CRDTExploreMapStateJSON { text: { value: string; timestamp: HLCTimestamp }; color: { value: string; timestamp: HLCTimestamp }; createdBy?: { value: string | undefined; timestamp: HLCTimestamp }; + frameId?: { value: string | undefined; timestamp: HLCTimestamp }; + frameOffsetX?: { value: number | undefined; timestamp: HLCTimestamp }; + frameOffsetY?: { value: number | undefined; timestamp: HLCTimestamp }; }>; panels: { adds: Record; @@ -257,6 +265,8 @@ export type CRDTOperationType = | 'update-postit-zindex' | 'update-postit-text' | 'update-postit-color' + | 'associate-postit-with-frame' + | 'disassociate-postit-from-frame' | 'batch'; // For batching multiple operations /** @@ -592,6 +602,29 @@ export interface UpdatePostItColorOperation extends CRDTOperationBase { }; } +/** + * Associate post-it note with frame operation + */ +export interface AssociatePostItWithFrameOperation extends CRDTOperationBase { + type: 'associate-postit-with-frame'; + payload: { + postItId: string; + frameId: string; + offsetX: number; + offsetY: number; + }; +} + +/** + * Disassociate post-it note from frame operation + */ +export interface DisassociatePostItFromFrameOperation extends CRDTOperationBase { + type: 'disassociate-postit-from-frame'; + payload: { + postItId: string; + }; +} + /** * Batch operation (multiple operations in one) @@ -633,6 +666,8 @@ export type CRDTOperation = | UpdatePostItZIndexOperation | UpdatePostItTextOperation | UpdatePostItColorOperation + | AssociatePostItWithFrameOperation + | DisassociatePostItFromFrameOperation | BatchOperation; /** diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts index b6542c65f6b..bde91a1058c 100644 --- a/public/app/features/explore-map/state/crdtSlice.ts +++ b/public/app/features/explore-map/state/crdtSlice.ts @@ -715,6 +715,55 @@ const crdtSlice = createSlice({ state.pendingOperations.push(operation); }, + /** + * Associate post-it note with frame + */ + associatePostItWithFrame: (state, action: PayloadAction<{ + postItId: string; + frameId: string; + offsetX: number; + offsetY: number; + }>) => { + const manager = getCRDTManager(state); + + const operation = manager.createAssociatePostItWithFrameOperation( + action.payload.postItId, + action.payload.frameId, + action.payload.offsetX, + action.payload.offsetY + ); + + if (!operation) { + return; + } + + manager.applyOperation(operation); + saveCRDTManager(state, manager); + state.pendingOperations.push(operation); + }, + + /** + * Disassociate post-it note from frame + */ + disassociatePostItFromFrame: (state, action: PayloadAction<{ + postItId: string; + }>) => { + const manager = getCRDTManager(state); + + const operation = manager.createDisassociatePostItFromFrameOperation( + action.payload.postItId + ); + + if (!operation) { + return; + } + + manager.applyOperation(operation); + saveCRDTManager(state, manager); + + state.pendingOperations.push(operation); + }, + /** * Duplicate a panel */ @@ -878,16 +927,36 @@ const crdtSlice = createSlice({ } } + // Create operations for all child sticky notes + const postItOps: CRDTOperation[] = []; + const childPostItIds = manager.getPostItNotesInFrame(action.payload.frameId); + + for (const postItId of childPostItIds) { + const postIt = manager.getPostItNoteData(postItId); + if (postIt) { + const newX = postIt.positionX.get() + deltaX; + const newY = postIt.positionY.get() + deltaY; + + const postItOp = manager.createUpdatePostItPositionOperation(postItId, newX, newY); + if (postItOp) { + postItOps.push(postItOp); + } + } + } + // Apply all operations manager.applyOperation(frameOp); for (const op of panelOps) { manager.applyOperation(op); } + for (const op of postItOps) { + manager.applyOperation(op); + } saveCRDTManager(state, manager); // Push all operations for broadcast - state.pendingOperations.push(frameOp, ...panelOps); + state.pendingOperations.push(frameOp, ...panelOps, ...postItOps); }, /** @@ -1294,6 +1363,8 @@ export const { bringPostItNoteToFront, updatePostItNoteText, updatePostItNoteColor, + associatePostItWithFrame, + disassociatePostItFromFrame, duplicatePanel, addFrame, removeFrame, diff --git a/public/app/features/explore-map/state/selectors.ts b/public/app/features/explore-map/state/selectors.ts index ad9fba2b0f0..21638ebadef 100644 --- a/public/app/features/explore-map/state/selectors.ts +++ b/public/app/features/explore-map/state/selectors.ts @@ -36,6 +36,12 @@ const panelCache = new Map(); */ const frameCache = new Map(); +/** + * Cache for individual post-it note objects + * Key: postItId, Value: { postIt object, serialized postIt data for comparison } + */ +const postItCache = new Map(); + /** * Select all panels as a Record (for compatibility with existing UI) * @@ -259,12 +265,66 @@ export const selectComments = createSelector( /** * Select all post-it notes as a Record + * + * OPTIMIZATION: This selector caches individual post-it note objects and only creates + * new post-it note objects when the underlying CRDT data for that specific post-it note changes. + * This prevents unnecessary re-renders of unchanged post-it notes when another post-it note is modified. */ export const selectPostItNotes = createSelector( - [(state: ExploreMapCRDTState) => state], - (state) => { + [ + (state: ExploreMapCRDTState) => state.crdtStateJSON, + (state: ExploreMapCRDTState) => state.nodeId, + (state: ExploreMapCRDTState) => state.uid, + ], + (crdtStateJSON, nodeId, uid): Record => { + const state: ExploreMapCRDTState = { + uid, + crdtStateJSON, + nodeId, + sessionId: '', // Not needed for post-it note selection + pendingOperations: [], + local: { + viewport: { zoom: 1, panX: 0, panY: 0 }, + selectedPanelIds: [], + cursors: {}, + cursorMode: 'pointer', + isOnline: false, + isSyncing: false, + }, + }; const manager = getCRDTManager(state); - return manager.getAllPostItNotesForUI(); + const postItNotes: Record = {}; + const currentPostItIds = new Set(manager.getPostItNoteIds()); + + // Clean up cache for removed post-it notes + for (const cachedPostItId of postItCache.keys()) { + if (!currentPostItIds.has(cachedPostItId)) { + postItCache.delete(cachedPostItId); + } + } + + // Build post-it notes object, reusing cached objects when data hasn't changed + for (const postItId of currentPostItIds) { + const postItData = manager.getPostItNoteForUI(postItId); + if (!postItData) { + continue; + } + + // Serialize the post-it note data to detect changes + const serializedData = JSON.stringify(postItData); + const cached = postItCache.get(postItId); + + // Reuse cached post-it note if data hasn't changed + if (cached && cached.data === serializedData) { + postItNotes[postItId] = cached.postIt; + } else { + // Post-it note is new or changed, create new object and cache it + postItNotes[postItId] = postItData; + postItCache.set(postItId, { postIt: postItData, data: serializedData }); + } + } + + return postItNotes; } );