Associate sticky notes with frames

This commit is contained in:
Aleksandar Petrov
2025-12-04 16:00:23 -04:00
parent 12c04d55f6
commit b8e86bb46f
5 changed files with 412 additions and 8 deletions
@@ -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 (
<Rnd
ref={rndRef}
position={{ x: postIt.position.x, y: postIt.position.y }}
position={{ x: effectiveX, y: effectiveY }}
size={{ width: postIt.position.width, height: postIt.position.height }}
scale={zoom}
onDragStart={handleDragStart}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
disableDragging={isDraggingDisabled}
bounds="parent"
className={styles.postItContainer}
style={{ zIndex: postIt.position.zIndex }}
@@ -45,6 +45,8 @@ import {
UpdatePostItZIndexOperation,
UpdatePostItTextOperation,
UpdatePostItColorOperation,
AssociatePostItWithFrameOperation,
DisassociatePostItFromFrameOperation,
OperationResult,
CRDTExploreMapStateJSON,
CommentData,
@@ -237,6 +239,20 @@ export class CRDTStateManager {
return panelIds;
}
/**
* Helper to get all sticky notes in a frame
*/
getPostItNotesInFrame(frameId: string): string[] {
const postItIds: string[] = [];
for (const postItId of this.getPostItNoteIds()) {
const postIt = this.state.postItNoteData.get(postItId);
if (postIt && postIt.frameId.get() === frameId) {
postItIds.push(postItId);
}
}
return postItIds;
}
/**
* Create an add panel operation
*/
@@ -872,6 +888,56 @@ export class CRDTStateManager {
};
}
/**
* Create an associate post-it note with frame operation
*/
createAssociatePostItWithFrameOperation(
postItId: string,
frameId: string,
offsetX: number,
offsetY: number
): AssociatePostItWithFrameOperation | null {
if (!this.state.postItNotes.contains(postItId)) {
return null;
}
const timestamp = this.clock.tick();
return {
type: 'associate-postit-with-frame',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: {
postItId,
frameId,
offsetX,
offsetY,
},
};
}
/**
* Create a disassociate post-it note from frame operation
*/
createDisassociatePostItFromFrameOperation(postItId: string): DisassociatePostItFromFrameOperation | null {
if (!this.state.postItNotes.contains(postItId)) {
return null;
}
const timestamp = this.clock.tick();
return {
type: 'disassociate-postit-from-frame',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: {
postItId,
},
};
}
/**
* Get all post-it note IDs
*/
@@ -910,6 +976,9 @@ export class CRDTStateManager {
text: data.text.get(),
color: data.color.get(),
createdBy: data.createdBy.get(),
frameId: data.frameId.get(),
frameOffsetX: data.frameOffsetX.get(),
frameOffsetY: data.frameOffsetY.get(),
};
}
@@ -922,6 +991,9 @@ export class CRDTStateManager {
text: string;
color: string;
createdBy?: string;
frameId?: string;
frameOffsetX?: number;
frameOffsetY?: number;
}> {
const postItNotes: Record<string, {
id: string;
@@ -929,6 +1001,9 @@ export class CRDTStateManager {
text: string;
color: string;
createdBy?: string;
frameId?: string;
frameOffsetX?: number;
frameOffsetY?: number;
}> = {};
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),
});
}
}
@@ -101,6 +101,11 @@ export interface CRDTPostItNoteData {
// Creator metadata
createdBy: LWWRegister<string | undefined>;
// Frame association properties
frameId: LWWRegister<string | undefined>;
frameOffsetX: LWWRegister<number | undefined>;
frameOffsetY: LWWRegister<number | undefined>;
}
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<string, string[]>;
@@ -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;
/**
@@ -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,
@@ -36,6 +36,12 @@ const panelCache = new Map<string, { panel: ExploreMapPanel; data: string }>();
*/
const frameCache = new Map<string, { frame: ExploreMapFrame; data: string }>();
/**
* Cache for individual post-it note objects
* Key: postItId, Value: { postIt object, serialized postIt data for comparison }
*/
const postItCache = new Map<string, { postIt: any; data: string }>();
/**
* 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<string, any> => {
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<string, any> = {};
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;
}
);