diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx
index 817947c8632..88f752f736d 100644
--- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx
+++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx
@@ -10,12 +10,13 @@ import { useTransformContext } from '../context/TransformContext';
import { useCursorSync } from '../hooks/useCursorSync';
import { useCursorViewportTracking } from '../hooks/useCursorViewportTracking';
import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice';
-import { selectPanels, selectFrames, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors';
+import { selectPanels, selectFrames, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid, selectPostItNotes } from '../state/selectors';
import { EdgeCursorIndicator } from './EdgeCursorIndicator';
import { ExploreMapComment } from './ExploreMapComment';
import { ExploreMapFrame } from './ExploreMapFrame';
import { ExploreMapPanelContainer } from './ExploreMapPanelContainer';
+import { ExploreMapStickyNote } from './ExploreMapStickyNote';
import { UserCursor } from './UserCursor';
interface SelectionRect {
@@ -38,6 +39,7 @@ export function ExploreMapCanvas() {
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
+ const postItNotes = useSelector((state) => selectPostItNotes(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT));
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
@@ -337,6 +339,9 @@ export function ExploreMapCanvas() {
.map((info) => (
))}
+ {Object.values(postItNotes).map((postIt) => (
+
+ ))}
{selectionRect && (
{
+ dispatch(
+ addPostItNote({
+ viewportSize: {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ },
+ createdBy: currentUsername,
+ })
+ );
+ setIsOpen(false);
+ }, [dispatch, currentUsername]);
// Build context for assistant
const canvasContext = useMemo(() => {
@@ -285,6 +300,11 @@ CRITICAL:
logoAlt="Pyroscope"
onClick={handleAddProfilesDrilldownPanel}
/>
+
);
diff --git a/public/app/features/explore-map/components/ExploreMapStickyNote.tsx b/public/app/features/explore-map/components/ExploreMapStickyNote.tsx
new file mode 100644
index 00000000000..0d8f3e1bbc7
--- /dev/null
+++ b/public/app/features/explore-map/components/ExploreMapStickyNote.tsx
@@ -0,0 +1,375 @@
+import { css, cx } from '@emotion/css';
+import React, { useCallback, useRef, useState } from 'react';
+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 { contextSrv } from 'app/core/services/context_srv';
+import { useDispatch } from 'app/types/store';
+
+import {
+ bringPostItNoteToFront,
+ removePostItNote,
+ updatePostItNoteColor,
+ updatePostItNotePosition,
+ updatePostItNoteSize,
+ updatePostItNoteText,
+} from '../state/crdtSlice';
+
+interface ExploreMapStickyNoteProps {
+ postIt: {
+ id: string;
+ position: { x: number; y: number; width: number; height: number; zIndex: number };
+ text: string;
+ color: string;
+ createdBy?: string;
+ };
+ zoom: number;
+}
+
+export const STICKY_NOTE_COLORS = [
+ { name: 'yellow', value: '#f1c40f' },
+ { name: 'blue', value: '#3498db' },
+ { name: 'green', value: '#2ecc71' },
+ { name: 'purple', value: '#9b59b6' },
+ { name: 'red', value: '#e74c3c' },
+];
+
+export function ExploreMapStickyNote({ postIt, zoom }: ExploreMapStickyNoteProps) {
+ const styles = useStyles2(getStyles);
+ const dispatch = useDispatch();
+ const rndRef = useRef
(null);
+ const textAreaRef = useRef(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editText, setEditText] = useState(postIt.text);
+ const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
+
+ const colorInfo = STICKY_NOTE_COLORS.find((c) => c.name === postIt.color) || STICKY_NOTE_COLORS.find((c) => c.name === 'purple') || STICKY_NOTE_COLORS[0];
+
+ const handleDragStart: RndDragCallback = useCallback(
+ (_e, data) => {
+ setDragStartPos({ x: data.x, y: data.y });
+ dispatch(bringPostItNoteToFront({ postItId: postIt.id }));
+ },
+ [dispatch, postIt.id]
+ );
+
+ const handleDragStop: RndDragCallback = useCallback(
+ (_e, data) => {
+ if (dragStartPos) {
+ const deltaX = data.x - dragStartPos.x;
+ const deltaY = data.y - dragStartPos.y;
+
+ // Only update position if there was actual movement
+ const hasMoved = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
+
+ if (hasMoved) {
+ dispatch(
+ updatePostItNotePosition({
+ postItId: postIt.id,
+ x: data.x,
+ y: data.y,
+ })
+ );
+ }
+ }
+ setDragStartPos(null);
+ },
+ [dispatch, postIt.id, dragStartPos]
+ );
+
+ const handleResizeStop: RndResizeCallback = useCallback(
+ (_e, _direction, ref, _delta, position) => {
+ const newWidth = ref.offsetWidth;
+ const newHeight = ref.offsetHeight;
+
+ // Update position
+ dispatch(
+ updatePostItNotePosition({
+ postItId: postIt.id,
+ x: position.x,
+ y: position.y,
+ })
+ );
+
+ // Update size
+ dispatch(
+ updatePostItNoteSize({
+ postItId: postIt.id,
+ width: newWidth,
+ height: newHeight,
+ })
+ );
+ },
+ [dispatch, postIt.id]
+ );
+
+ const handleDoubleClick = useCallback(() => {
+ setIsEditing(true);
+ setEditText(postIt.text);
+ setTimeout(() => {
+ textAreaRef.current?.focus();
+ textAreaRef.current?.select();
+ }, 0);
+ }, [postIt.text]);
+
+ const handleSaveText = useCallback(() => {
+ dispatch(
+ updatePostItNoteText({
+ postItId: postIt.id,
+ text: editText,
+ })
+ );
+ setIsEditing(false);
+ }, [dispatch, postIt.id, editText]);
+
+ const handleCancelEdit = useCallback(() => {
+ setEditText(postIt.text);
+ setIsEditing(false);
+ }, [postIt.text]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleCancelEdit();
+ } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+ e.preventDefault();
+ handleSaveText();
+ }
+ },
+ [handleCancelEdit, handleSaveText]
+ );
+
+ const handleDelete = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (window.confirm(t('explore-map.sticky.delete-confirm', 'Delete this sticky note?'))) {
+ dispatch(removePostItNote({ postItId: postIt.id }));
+ }
+ },
+ [dispatch, postIt.id]
+ );
+
+ const handleColorChange = useCallback(
+ (color: string) => {
+ dispatch(
+ updatePostItNoteColor({
+ postItId: postIt.id,
+ color,
+ })
+ );
+ },
+ [dispatch, postIt.id]
+ );
+
+ return (
+
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
{postIt.text || t('explore-map.sticky.empty', 'Double-click to edit')}
+
+
+ {STICKY_NOTE_COLORS.map((color) => (
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ postItContainer: css({
+ cursor: 'move',
+ '&:hover': {
+ boxShadow: theme.shadows.z3,
+ },
+ }),
+ postItContent: css({
+ width: '100%',
+ height: '100%',
+ padding: theme.spacing(1.5),
+ display: 'flex',
+ flexDirection: 'column',
+ borderRadius: theme.shape.radius.default,
+ boxShadow: theme.shadows.z2,
+ position: 'relative',
+ overflow: 'hidden',
+ // Add a subtle border for better definition
+ border: `1px solid rgba(0, 0, 0, 0.1)`,
+ [theme.transitions.handleMotion('no-preference', 'reduce')]: {
+ transition: 'box-shadow 0.2s',
+ },
+ }),
+ textContent: css({
+ flex: 1,
+ fontSize: theme.typography.body.fontSize,
+ // Use darker color for better contrast on bright sticky note backgrounds
+ color: theme.colors.text.maxContrast,
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ overflow: 'auto',
+ minHeight: '60px',
+ lineHeight: theme.typography.body.lineHeight,
+ fontFamily: theme.typography.fontFamilyMonospace,
+ fontWeight: theme.typography.fontWeightMedium,
+ // Add text shadow for better readability on bright backgrounds
+ textShadow: '0 1px 3px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.3)',
+ }),
+ editor: css({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ gap: theme.spacing(1),
+ }),
+ textArea: css({
+ flex: 1,
+ resize: 'none',
+ fontFamily: theme.typography.fontFamilyMonospace,
+ fontWeight: theme.typography.fontWeightMedium,
+ // Use a more opaque white background for better contrast
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
+ border: `2px solid rgba(0, 0, 0, 0.3)`,
+ color: theme.colors.text.maxContrast,
+ padding: theme.spacing(1),
+ borderRadius: theme.shape.radius.default,
+ // Ensure text is clearly visible
+ fontSize: theme.typography.body.fontSize,
+ lineHeight: theme.typography.body.lineHeight,
+ '&:focus': {
+ backgroundColor: theme.colors.background.primary,
+ borderColor: theme.colors.primary.border,
+ outline: 'none',
+ boxShadow: `0 0 0 2px ${theme.colors.primary.transparent}`,
+ },
+ }),
+ editorActions: css({
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: theme.spacing(1),
+ }),
+ actions: css({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: theme.spacing(1),
+ gap: theme.spacing(1),
+ }),
+ colorPicker: css({
+ display: 'flex',
+ gap: theme.spacing(0.5),
+ }),
+ colorButton: css({
+ width: '20px',
+ height: '20px',
+ borderRadius: theme.shape.radius.default,
+ border: `2px solid ${theme.colors.border.weak}`,
+ cursor: 'pointer',
+ padding: 0,
+ [theme.transitions.handleMotion('no-preference', 'reduce')]: {
+ transition: 'transform 0.2s, border-color 0.2s',
+ },
+ '&:hover': {
+ transform: 'scale(1.1)',
+ borderColor: theme.colors.border.medium,
+ },
+ }),
+ colorButtonActive: css({
+ borderColor: theme.colors.text.primary,
+ borderWidth: '3px',
+ }),
+ deleteButton: css({
+ border: `1px solid ${theme.colors.border.weak}`,
+ borderRadius: theme.shape.radius.default,
+ padding: theme.spacing(0.5, 1),
+ color: '#555555',
+ opacity: 1,
+ [theme.transitions.handleMotion('no-preference', 'reduce')]: {
+ transition: 'border-color 0.2s, color 0.2s',
+ },
+ '&:hover': {
+ borderColor: theme.colors.border.medium,
+ color: '#333333',
+ },
+ }),
+ cancelButton: css({
+ backgroundColor: theme.colors.background.secondary,
+ borderRadius: theme.shape.radius.default,
+ [theme.transitions.handleMotion('no-preference', 'reduce')]: {
+ transition: 'background-color 0.2s',
+ },
+ '&:hover': {
+ backgroundColor: theme.colors.background.canvas,
+ },
+ }),
+ };
+};
+
diff --git a/public/app/features/explore-map/crdt/state.ts b/public/app/features/explore-map/crdt/state.ts
index 138fbc9895f..5b0fb8920aa 100644
--- a/public/app/features/explore-map/crdt/state.ts
+++ b/public/app/features/explore-map/crdt/state.ts
@@ -17,6 +17,7 @@ import {
CRDTExploreMapState,
CRDTPanelData,
CRDTFrameData,
+ CRDTPostItNoteData,
CRDTOperation,
AddPanelOperation,
RemovePanelOperation,
@@ -35,6 +36,13 @@ import {
UpdateFrameTitleOperation,
AssociatePanelWithFrameOperation,
DisassociatePanelFromFrameOperation,
+ AddPostItOperation,
+ RemovePostItOperation,
+ UpdatePostItPositionOperation,
+ UpdatePostItSizeOperation,
+ UpdatePostItZIndexOperation,
+ UpdatePostItTextOperation,
+ UpdatePostItColorOperation,
OperationResult,
CRDTExploreMapStateJSON,
CommentData,
@@ -62,6 +70,8 @@ export class CRDTStateManager {
title: createLWWRegister('Untitled Map', this.nodeId),
comments: new ORSet(),
commentData: new Map(),
+ postItNotes: new ORSet(),
+ postItNoteData: new Map(),
panels: new ORSet(),
panelData: new Map(),
frames: new ORSet(),
@@ -647,6 +657,238 @@ export class CRDTStateManager {
return comments.sort((a, b) => b.data.timestamp - a.data.timestamp);
}
+ /**
+ * Create an add post-it note operation
+ */
+ createAddPostItOperation(
+ postItId: string,
+ position: { x: number; y: number; width: number; height: number },
+ text?: string,
+ color?: string,
+ createdBy?: string
+ ): AddPostItOperation {
+ const timestamp = this.clock.tick();
+ return {
+ type: 'add-postit',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ postItId,
+ position,
+ text: text || '',
+ color: color || 'purple',
+ createdBy,
+ },
+ };
+ }
+
+ /**
+ * Create a remove post-it note operation
+ */
+ createRemovePostItOperation(postItId: string): RemovePostItOperation | null {
+ if (!this.state.postItNotes.contains(postItId)) {
+ return null;
+ }
+
+ const timestamp = this.clock.tick();
+ const observedTags = this.state.postItNotes.getTags(postItId);
+
+ return {
+ type: 'remove-postit',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ postItId,
+ observedTags,
+ },
+ };
+ }
+
+ /**
+ * Create an update post-it note position operation
+ */
+ createUpdatePostItPositionOperation(postItId: string, x: number, y: number): UpdatePostItPositionOperation | null {
+ if (!this.state.postItNotes.contains(postItId)) {
+ return null;
+ }
+
+ const timestamp = this.clock.tick();
+ return {
+ type: 'update-postit-position',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ postItId,
+ x,
+ y,
+ },
+ };
+ }
+
+ /**
+ * Create an update post-it note size operation
+ */
+ createUpdatePostItSizeOperation(postItId: string, width: number, height: number): UpdatePostItSizeOperation | null {
+ if (!this.state.postItNotes.contains(postItId)) {
+ return null;
+ }
+
+ const timestamp = this.clock.tick();
+ return {
+ type: 'update-postit-size',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ postItId,
+ width,
+ height,
+ },
+ };
+ }
+
+ /**
+ * Create an update post-it note z-index operation
+ */
+ createUpdatePostItZIndexOperation(postItId: string): UpdatePostItZIndexOperation | null {
+ if (!this.state.postItNotes.contains(postItId)) {
+ return null;
+ }
+
+ const timestamp = this.clock.tick();
+ const zIndex = this.state.zIndexCounter.next(this.nodeId);
+
+ return {
+ type: 'update-postit-zindex',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ postItId,
+ zIndex,
+ },
+ };
+ }
+
+ /**
+ * Create an update post-it note text operation
+ */
+ createUpdatePostItTextOperation(postItId: string, text: string): UpdatePostItTextOperation | null {
+ if (!this.state.postItNotes.contains(postItId)) {
+ return null;
+ }
+
+ const timestamp = this.clock.tick();
+ return {
+ type: 'update-postit-text',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ postItId,
+ text,
+ },
+ };
+ }
+
+ /**
+ * Create an update post-it note color operation
+ */
+ createUpdatePostItColorOperation(postItId: string, color: string): UpdatePostItColorOperation | null {
+ if (!this.state.postItNotes.contains(postItId)) {
+ return null;
+ }
+
+ const timestamp = this.clock.tick();
+ return {
+ type: 'update-postit-color',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ postItId,
+ color,
+ },
+ };
+ }
+
+ /**
+ * Get all post-it note IDs
+ */
+ getPostItNoteIds(): string[] {
+ return this.state.postItNotes.values();
+ }
+
+ /**
+ * Get post-it note data by ID
+ */
+ getPostItNoteData(postItId: string): CRDTPostItNoteData | undefined {
+ if (!this.state.postItNotes.contains(postItId)) {
+ return undefined;
+ }
+ return this.state.postItNoteData.get(postItId);
+ }
+
+ /**
+ * Get a plain object representation of a post-it note for UI rendering
+ */
+ getPostItNoteForUI(postItId: string) {
+ const data = this.getPostItNoteData(postItId);
+ if (!data) {
+ return undefined;
+ }
+
+ return {
+ id: data.id,
+ position: {
+ x: data.positionX.get(),
+ y: data.positionY.get(),
+ width: data.width.get(),
+ height: data.height.get(),
+ zIndex: data.zIndex.get(),
+ },
+ text: data.text.get(),
+ color: data.color.get(),
+ createdBy: data.createdBy.get(),
+ };
+ }
+
+ /**
+ * Get all post-it notes for UI rendering
+ */
+ getAllPostItNotesForUI(): Record {
+ const postItNotes: Record = {};
+ for (const postItId of this.getPostItNoteIds()) {
+ const postIt = this.getPostItNoteForUI(postItId);
+ if (postIt) {
+ postItNotes[postItId] = postIt;
+ }
+ }
+ return postItNotes;
+ }
+
/**
* Apply a CRDT operation to the state
*/
@@ -690,6 +932,20 @@ export class CRDTStateManager {
return this.applyAssociatePanelWithFrame(operation);
case 'disassociate-panel-from-frame':
return this.applyDisassociatePanelFromFrame(operation);
+ case 'add-postit':
+ return this.applyAddPostIt(operation);
+ case 'remove-postit':
+ return this.applyRemovePostIt(operation);
+ case 'update-postit-position':
+ return this.applyUpdatePostItPosition(operation);
+ case 'update-postit-size':
+ return this.applyUpdatePostItSize(operation);
+ case 'update-postit-zindex':
+ return this.applyUpdatePostItZIndex(operation);
+ case 'update-postit-text':
+ return this.applyUpdatePostItText(operation);
+ case 'update-postit-color':
+ return this.applyUpdatePostItColor(operation);
case 'batch':
return this.applyBatchOperation(operation);
default:
@@ -901,6 +1157,136 @@ export class CRDTStateManager {
};
}
+ private applyAddPostIt(operation: AddPostItOperation): OperationResult {
+ const { postItId, position, text, color, createdBy } = operation.payload;
+
+ // Add to OR-Set with operation ID as tag
+ this.state.postItNotes.add(postItId, operation.operationId);
+
+ // Initialize post-it note data if it doesn't exist
+ if (!this.state.postItNoteData.has(postItId)) {
+ const zIndex = this.state.zIndexCounter.next(operation.nodeId);
+
+ this.state.postItNoteData.set(postItId, {
+ id: postItId,
+ positionX: new LWWRegister(position.x, operation.timestamp),
+ positionY: new LWWRegister(position.y, operation.timestamp),
+ width: new LWWRegister(position.width, operation.timestamp),
+ height: new LWWRegister(position.height, operation.timestamp),
+ zIndex: new LWWRegister(zIndex, operation.timestamp),
+ text: new LWWRegister(text || '', operation.timestamp),
+ color: new LWWRegister(color || 'purple', operation.timestamp),
+ createdBy: new LWWRegister(createdBy, operation.timestamp),
+ });
+ }
+
+ return { success: true, applied: true };
+ }
+
+ private applyRemovePostIt(operation: RemovePostItOperation): OperationResult {
+ const { postItId, observedTags } = operation.payload;
+
+ // Check if post-it note exists before removing
+ const existed = this.state.postItNotes.contains(postItId);
+
+ // Remove from OR-Set
+ this.state.postItNotes.remove(postItId, observedTags);
+
+ // Remove post-it note data if it existed
+ if (existed) {
+ this.state.postItNoteData.delete(postItId);
+ }
+
+ return {
+ success: true,
+ applied: existed,
+ };
+ }
+
+ private applyUpdatePostItPosition(operation: UpdatePostItPositionOperation): OperationResult {
+ const { postItId, x, y } = operation.payload;
+ const postItData = this.state.postItNoteData.get(postItId);
+
+ if (!postItData) {
+ return { success: true, applied: false, error: 'Post-it note not found' };
+ }
+
+ const xUpdated = postItData.positionX.set(x, operation.timestamp);
+ const yUpdated = postItData.positionY.set(y, operation.timestamp);
+ const updated = xUpdated || yUpdated;
+
+ return {
+ success: true,
+ applied: updated,
+ };
+ }
+
+ private applyUpdatePostItSize(operation: UpdatePostItSizeOperation): OperationResult {
+ const { postItId, width, height } = operation.payload;
+ const postItData = this.state.postItNoteData.get(postItId);
+
+ if (!postItData) {
+ return { success: true, applied: false, error: 'Post-it note not found' };
+ }
+
+ const widthUpdated = postItData.width.set(width, operation.timestamp);
+ const heightUpdated = postItData.height.set(height, operation.timestamp);
+ const updated = widthUpdated || heightUpdated;
+
+ return {
+ success: true,
+ applied: updated,
+ };
+ }
+
+ private applyUpdatePostItZIndex(operation: UpdatePostItZIndexOperation): OperationResult {
+ const { postItId, zIndex } = operation.payload;
+ const postItData = this.state.postItNoteData.get(postItId);
+
+ if (!postItData) {
+ return { success: true, applied: false, error: 'Post-it note not found' };
+ }
+
+ const updated = postItData.zIndex.set(zIndex, operation.timestamp);
+
+ return {
+ success: true,
+ applied: updated,
+ };
+ }
+
+ private applyUpdatePostItText(operation: UpdatePostItTextOperation): OperationResult {
+ const { postItId, text } = operation.payload;
+ const postItData = this.state.postItNoteData.get(postItId);
+
+ if (!postItData) {
+ return { success: true, applied: false, error: 'Post-it note not found' };
+ }
+
+ const updated = postItData.text.set(text, operation.timestamp);
+
+ return {
+ success: true,
+ applied: updated,
+ };
+ }
+
+ private applyUpdatePostItColor(operation: UpdatePostItColorOperation): OperationResult {
+ const { postItId, color } = operation.payload;
+ const postItData = this.state.postItNoteData.get(postItId);
+
+ if (!postItData) {
+ return { success: true, applied: false, error: 'Post-it note not found' };
+ }
+
+ const updated = postItData.color.set(color, operation.timestamp);
+
+ return {
+ success: true,
+ applied: updated,
+ };
+ }
+
private applyBatchOperation(operation: BatchOperation): OperationResult {
let anyApplied = false;
const errors: string[] = [];
@@ -1063,6 +1449,39 @@ export class CRDTStateManager {
}
}
+ // Merge post-it notes OR-Set
+ this.state.postItNotes.merge(other.postItNotes);
+
+ // Merge post-it note data
+ for (const [postItId, otherPostItData] of other.postItNoteData.entries()) {
+ const myPostItData = this.state.postItNoteData.get(postItId);
+
+ if (!myPostItData) {
+ // Post-it note doesn't exist locally - copy it
+ this.state.postItNoteData.set(postItId, {
+ id: otherPostItData.id,
+ positionX: otherPostItData.positionX.clone(),
+ positionY: otherPostItData.positionY.clone(),
+ width: otherPostItData.width.clone(),
+ height: otherPostItData.height.clone(),
+ zIndex: otherPostItData.zIndex.clone(),
+ text: otherPostItData.text.clone(),
+ color: otherPostItData.color.clone(),
+ createdBy: otherPostItData.createdBy.clone(),
+ });
+ } else {
+ // Merge each LWW register
+ myPostItData.positionX.merge(otherPostItData.positionX);
+ myPostItData.positionY.merge(otherPostItData.positionY);
+ myPostItData.width.merge(otherPostItData.width);
+ myPostItData.height.merge(otherPostItData.height);
+ myPostItData.zIndex.merge(otherPostItData.zIndex);
+ myPostItData.text.merge(otherPostItData.text);
+ myPostItData.color.merge(otherPostItData.color);
+ myPostItData.createdBy.merge(otherPostItData.createdBy);
+ }
+ }
+
// Merge panel OR-Set
this.state.panels.merge(other.panels);
@@ -1192,11 +1611,30 @@ export class CRDTStateManager {
}
}
+ const postItNoteData: CRDTExploreMapStateJSON['postItNoteData'] = {};
+ for (const [postItId, data] of this.state.postItNoteData.entries()) {
+ if (this.state.postItNotes.contains(postItId)) {
+ postItNoteData[postItId] = {
+ id: data.id,
+ positionX: data.positionX.toJSON(),
+ positionY: data.positionY.toJSON(),
+ width: data.width.toJSON(),
+ height: data.height.toJSON(),
+ zIndex: data.zIndex.toJSON(),
+ text: data.text.toJSON(),
+ color: data.color.toJSON(),
+ createdBy: data.createdBy.toJSON(),
+ };
+ }
+ }
+
return {
uid: this.state.uid,
title: this.state.title.toJSON(),
comments: this.state.comments.toJSON(),
commentData,
+ postItNotes: this.state.postItNotes.toJSON(),
+ postItNoteData,
panels: this.state.panels.toJSON(),
panelData,
frames: this.state.frames.toJSON(),
@@ -1220,6 +1658,24 @@ export class CRDTStateManager {
manager.state.commentData.set(commentId, data);
}
}
+ manager.state.postItNotes = json.postItNotes ? ORSet.fromJSON(json.postItNotes) : new ORSet();
+ manager.state.postItNoteData = new Map();
+ if (json.postItNoteData) {
+ for (const [postItId, data] of Object.entries(json.postItNoteData)) {
+ const defaultTimestamp = data.positionX?.timestamp || { nodeId: manager.nodeId, counter: 0, wallClock: Date.now() };
+ manager.state.postItNoteData.set(postItId, {
+ id: data.id,
+ positionX: LWWRegister.fromJSON(data.positionX),
+ positionY: LWWRegister.fromJSON(data.positionY),
+ width: LWWRegister.fromJSON(data.width),
+ height: LWWRegister.fromJSON(data.height),
+ zIndex: LWWRegister.fromJSON(data.zIndex),
+ text: LWWRegister.fromJSON(data.text),
+ color: LWWRegister.fromJSON(data.color),
+ createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp),
+ });
+ }
+ }
manager.state.panels = ORSet.fromJSON(json.panels);
manager.state.frames = json.frames ? ORSet.fromJSON(json.frames) : new ORSet();
manager.state.zIndexCounter = PNCounter.fromJSON(json.zIndexCounter);
diff --git a/public/app/features/explore-map/crdt/types.ts b/public/app/features/explore-map/crdt/types.ts
index 0343af7ec1e..a12cbdf5df7 100644
--- a/public/app/features/explore-map/crdt/types.ts
+++ b/public/app/features/explore-map/crdt/types.ts
@@ -79,6 +79,28 @@ export interface CommentData {
timestamp: number; // Unix timestamp in milliseconds
}
+/**
+ * CRDT state for a single post-it note
+ */
+export interface CRDTPostItNoteData {
+ // Stable identifier
+ id: string;
+
+ // CRDT-replicated position properties
+ positionX: LWWRegister;
+ positionY: LWWRegister;
+ width: LWWRegister;
+ height: LWWRegister;
+ zIndex: LWWRegister;
+
+ // CRDT-replicated content
+ text: LWWRegister;
+ color: LWWRegister; // Color theme (e.g., 'yellow', 'pink', 'blue', 'green')
+
+ // Creator metadata
+ createdBy: LWWRegister;
+}
+
export interface CRDTExploreMapState {
// Map metadata
uid?: string;
@@ -90,6 +112,12 @@ export interface CRDTExploreMapState {
// Comment data (text, username, timestamp)
commentData: Map;
+ // Post-it note collection (OR-Set for add/remove operations)
+ postItNotes: ORSet; // Set of post-it note IDs
+
+ // Post-it note data (position, size, content, color)
+ postItNoteData: Map;
+
// Panel collection (OR-Set for add/remove operations)
panels: ORSet; // Set of panel IDs
@@ -138,6 +166,21 @@ export interface CRDTExploreMapStateJSON {
removes: string[];
};
commentData?: Record;
+ postItNotes?: {
+ adds: Record;
+ removes: string[];
+ };
+ postItNoteData?: Record;
panels: {
adds: Record;
removes: string[];
@@ -201,6 +244,13 @@ export type CRDTOperationType =
| 'update-frame-title'
| 'associate-panel-with-frame'
| 'disassociate-panel-from-frame'
+ | 'add-postit'
+ | 'remove-postit'
+ | 'update-postit-position'
+ | 'update-postit-size'
+ | 'update-postit-zindex'
+ | 'update-postit-text'
+ | 'update-postit-color'
| 'batch'; // For batching multiple operations
/**
@@ -423,6 +473,96 @@ export interface DisassociatePanelFromFrameOperation extends CRDTOperationBase {
};
}
+
+
+/**
+ * Add post-it note operation
+ */
+export interface AddPostItOperation extends CRDTOperationBase {
+ type: 'add-postit';
+ payload: {
+ postItId: string;
+ position: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ };
+ text?: string;
+ color?: string;
+ createdBy?: string;
+ };
+}
+
+/**
+ * Remove post-it note operation
+ */
+export interface RemovePostItOperation extends CRDTOperationBase {
+ type: 'remove-postit';
+ payload: {
+ postItId: string;
+ observedTags: string[]; // Tags from OR-Set
+ };
+}
+
+/**
+ * Update post-it note position operation
+ */
+export interface UpdatePostItPositionOperation extends CRDTOperationBase {
+ type: 'update-postit-position';
+ payload: {
+ postItId: string;
+ x: number;
+ y: number;
+ };
+}
+
+/**
+ * Update post-it note size operation
+ */
+export interface UpdatePostItSizeOperation extends CRDTOperationBase {
+ type: 'update-postit-size';
+ payload: {
+ postItId: string;
+ width: number;
+ height: number;
+ };
+}
+
+/**
+ * Update post-it note z-index operation
+ */
+export interface UpdatePostItZIndexOperation extends CRDTOperationBase {
+ type: 'update-postit-zindex';
+ payload: {
+ postItId: string;
+ zIndex: number;
+ };
+}
+
+/**
+ * Update post-it note text operation
+ */
+export interface UpdatePostItTextOperation extends CRDTOperationBase {
+ type: 'update-postit-text';
+ payload: {
+ postItId: string;
+ text: string;
+ };
+}
+
+/**
+ * Update post-it note color operation
+ */
+export interface UpdatePostItColorOperation extends CRDTOperationBase {
+ type: 'update-postit-color';
+ payload: {
+ postItId: string;
+ color: string;
+ };
+}
+
+
/**
* Batch operation (multiple operations in one)
*/
@@ -454,6 +594,13 @@ export type CRDTOperation =
| UpdateFrameTitleOperation
| AssociatePanelWithFrameOperation
| DisassociatePanelFromFrameOperation
+ | AddPostItOperation
+ | RemovePostItOperation
+ | UpdatePostItPositionOperation
+ | UpdatePostItSizeOperation
+ | UpdatePostItZIndexOperation
+ | UpdatePostItTextOperation
+ | UpdatePostItColorOperation
| BatchOperation;
/**
diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts
index 16d285099d8..ca62dfabe78 100644
--- a/public/app/features/explore-map/state/crdtSlice.ts
+++ b/public/app/features/explore-map/state/crdtSlice.ts
@@ -541,6 +541,176 @@ const crdtSlice = createSlice({
state.pendingOperations.push(operation);
},
+ /**
+ * Add a sticky note
+ */
+ addPostItNote: (state, action: PayloadAction<{
+ viewportSize?: { width: number; height: number };
+ position?: { x: number; y: number; width: number; height: number };
+ text?: string;
+ color?: string;
+ createdBy?: string;
+ }>) => {
+ const manager = getCRDTManager(state);
+
+ // Calculate position
+ const viewportSize = action.payload.viewportSize || { width: 1920, height: 1080 };
+ const canvasCenterX = (-state.local.viewport.panX + viewportSize.width / 2) / state.local.viewport.zoom;
+ const canvasCenterY = (-state.local.viewport.panY + viewportSize.height / 2) / state.local.viewport.zoom;
+
+ const defaultWidth = 200;
+ const defaultHeight = 200;
+ const postItCount = manager.getPostItNoteIds().length;
+ const offset = postItCount * 30;
+
+ const position = action.payload.position || {
+ x: canvasCenterX - defaultWidth / 2 + offset,
+ y: canvasCenterY - defaultHeight / 2 + offset,
+ width: defaultWidth,
+ height: defaultHeight,
+ };
+
+ // Create operation
+ const postItId = uuidv4();
+ const operation = manager.createAddPostItOperation(
+ postItId,
+ position,
+ action.payload.text,
+ action.payload.color,
+ action.payload.createdBy
+ );
+
+ // Apply locally
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+
+ // Add to pending operations for broadcast
+ state.pendingOperations.push(operation);
+ },
+
+ /**
+ * Remove a sticky note
+ */
+ removePostItNote: (state, action: PayloadAction<{ postItId: string }>) => {
+ const manager = getCRDTManager(state);
+
+ const operation = manager.createRemovePostItOperation(action.payload.postItId);
+ if (!operation) {
+ return; // Sticky note doesn't exist
+ }
+
+ // Apply locally
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+
+ // Add to pending operations
+ state.pendingOperations.push(operation);
+ },
+
+ /**
+ * Update sticky note position
+ */
+ updatePostItNotePosition: (
+ state,
+ action: PayloadAction<{ postItId: string; x: number; y: number }>
+ ) => {
+ const manager = getCRDTManager(state);
+
+ const operation = manager.createUpdatePostItPositionOperation(
+ action.payload.postItId,
+ action.payload.x,
+ action.payload.y
+ );
+
+ if (!operation) {
+ return;
+ }
+
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+ state.pendingOperations.push(operation);
+ },
+
+ /**
+ * Update sticky note size
+ */
+ updatePostItNoteSize: (
+ state,
+ action: PayloadAction<{ postItId: string; width: number; height: number }>
+ ) => {
+ const manager = getCRDTManager(state);
+
+ const operation = manager.createUpdatePostItSizeOperation(
+ action.payload.postItId,
+ action.payload.width,
+ action.payload.height
+ );
+
+ if (!operation) {
+ return;
+ }
+
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+ state.pendingOperations.push(operation);
+ },
+
+ /**
+ * Bring sticky note to front
+ */
+ bringPostItNoteToFront: (state, action: PayloadAction<{ postItId: string }>) => {
+ const manager = getCRDTManager(state);
+
+ const operation = manager.createUpdatePostItZIndexOperation(action.payload.postItId);
+ if (!operation) {
+ return;
+ }
+
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+ state.pendingOperations.push(operation);
+ },
+
+ /**
+ * Update sticky note text
+ */
+ updatePostItNoteText: (state, action: PayloadAction<{ postItId: string; text: string }>) => {
+ const manager = getCRDTManager(state);
+
+ const operation = manager.createUpdatePostItTextOperation(
+ action.payload.postItId,
+ action.payload.text
+ );
+
+ if (!operation) {
+ return;
+ }
+
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+ state.pendingOperations.push(operation);
+ },
+
+ /**
+ * Update sticky note color
+ */
+ updatePostItNoteColor: (state, action: PayloadAction<{ postItId: string; color: string }>) => {
+ const manager = getCRDTManager(state);
+
+ const operation = manager.createUpdatePostItColorOperation(
+ action.payload.postItId,
+ action.payload.color
+ );
+
+ if (!operation) {
+ return;
+ }
+
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+ state.pendingOperations.push(operation);
+ },
+
/**
* Duplicate a panel
*/
@@ -947,6 +1117,13 @@ export const {
updateMapTitle,
addComment,
removeComment,
+ addPostItNote,
+ removePostItNote,
+ updatePostItNotePosition,
+ updatePostItNoteSize,
+ bringPostItNoteToFront,
+ updatePostItNoteText,
+ updatePostItNoteColor,
duplicatePanel,
addFrame,
removeFrame,
diff --git a/public/app/features/explore-map/state/selectors.ts b/public/app/features/explore-map/state/selectors.ts
index fa5950b349a..8b986c868e6 100644
--- a/public/app/features/explore-map/state/selectors.ts
+++ b/public/app/features/explore-map/state/selectors.ts
@@ -255,6 +255,17 @@ export const selectComments = createSelector(
}
);
+/**
+ * Select all post-it notes as a Record
+ */
+export const selectPostItNotes = createSelector(
+ [(state: ExploreMapCRDTState) => state],
+ (state) => {
+ const manager = getCRDTManager(state);
+ return manager.getAllPostItNotesForUI();
+ }
+);
+
/**
* Select map UID
*/