Fix sticky note conflicts with frames

This commit is contained in:
Joey
2025-12-04 12:09:43 +00:00
parent 66ed7e526a
commit 91ba4bc419
7 changed files with 1193 additions and 2 deletions
@@ -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) => (
<UserCursor key={info.cursor.sessionId} cursor={info.cursor} zoom={viewport.zoom} />
))}
{Object.values(postItNotes).map((postIt) => (
<ExploreMapStickyNote key={postIt.id} postIt={postIt} zoom={viewport.zoom} />
))}
{selectionRect && (
<div
className={styles.selectionRect}
@@ -13,8 +13,10 @@ import prometheusLogoSvg from 'app/plugins/datasource/prometheus/img/prometheus_
import tempoLogoSvg from 'app/plugins/datasource/tempo/img/tempo_logo.svg';
import { useDispatch, useSelector } from 'app/types/store';
import { addPanel, addFrame } from '../state/crdtSlice';
import { addPanel, addFrame, addPostItNote } from '../state/crdtSlice';
import { selectPanels, selectMapUid, selectViewport, selectSelectedPanelIds } from '../state/selectors';
import { selectPanels, selectMapUid } from '../state/selectors';
import { AddPanelAction } from './AssistantComponents';
export function ExploreMapFloatingToolbar() {
@@ -155,6 +157,19 @@ export function ExploreMapFloatingToolbar() {
})
);
}, [dispatch, currentUsername, selectedPanelIds, panels, viewport]);
const handleAddPostItNote = useCallback(() => {
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}
/>
<MenuItem
label={t('explore-map.toolbar.add-sticky', 'Add Sticky note')}
icon="file-alt"
onClick={handleAddPostItNote}
/>
</Menu>
);
@@ -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<Rnd>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(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 (
<Rnd
ref={rndRef}
position={{ x: postIt.position.x, y: postIt.position.y }}
size={{ width: postIt.position.width, height: postIt.position.height }}
scale={zoom}
onDragStart={handleDragStart}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
bounds="parent"
className={styles.postItContainer}
style={{ zIndex: postIt.position.zIndex }}
minWidth={150}
minHeight={150}
maxWidth={400}
maxHeight={400}
>
<div
className={styles.postItContent}
style={{ backgroundColor: colorInfo.value }}
onDoubleClick={handleDoubleClick}
>
{isEditing ? (
<div className={styles.editor}>
<TextArea
ref={textAreaRef}
value={editText}
onChange={(e) => setEditText(e.currentTarget.value)}
onKeyDown={handleKeyDown}
onBlur={handleSaveText}
className={styles.textArea}
rows={Math.max(3, Math.floor(postIt.position.height / 30) - 2)}
autoFocus
/>
<div className={styles.editorActions}>
<Button
variant="secondary"
size="sm"
onClick={handleCancelEdit}
icon="times"
className={styles.cancelButton}
>
{t('explore-map.sticky.cancel', 'Cancel')}
</Button>
</div>
</div>
) : (
<>
<div className={styles.textContent}>{postIt.text || t('explore-map.sticky.empty', 'Double-click to edit')}</div>
<div className={styles.actions}>
<div className={styles.colorPicker}>
{STICKY_NOTE_COLORS.map((color) => (
<button
key={color.name}
className={cx(styles.colorButton, postIt.color === color.name && styles.colorButtonActive)}
style={{ backgroundColor: color.value }}
onClick={(e) => {
e.stopPropagation();
handleColorChange(color.name);
}}
title={color.name}
aria-label={t('explore-map.sticky.color', 'Change color to {{color}}', { color: color.name })}
/>
))}
</div>
<Button
variant="secondary"
size="sm"
fill="text"
icon="trash-alt"
onClick={handleDelete}
className={styles.deleteButton}
tooltip={t('explore-map.sticky.delete', 'Delete sticky note')}
/>
</div>
</>
)}
</div>
</Rnd>
);
}
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,
},
}),
};
};
@@ -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<string>(),
commentData: new Map(),
postItNotes: new ORSet<string>(),
postItNoteData: new Map(),
panels: new ORSet<string>(),
panelData: new Map(),
frames: new ORSet<string>(),
@@ -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<string, {
id: string;
position: { x: number; y: number; width: number; height: number; zIndex: number };
text: string;
color: string;
createdBy?: string;
}> {
const postItNotes: Record<string, {
id: string;
position: { x: number; y: number; width: number; height: number; zIndex: number };
text: string;
color: string;
createdBy?: string;
}> = {};
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<string>();
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<string>();
manager.state.zIndexCounter = PNCounter.fromJSON(json.zIndexCounter);
@@ -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<number>;
positionY: LWWRegister<number>;
width: LWWRegister<number>;
height: LWWRegister<number>;
zIndex: LWWRegister<number>;
// CRDT-replicated content
text: LWWRegister<string>;
color: LWWRegister<string>; // Color theme (e.g., 'yellow', 'pink', 'blue', 'green')
// Creator metadata
createdBy: LWWRegister<string | undefined>;
}
export interface CRDTExploreMapState {
// Map metadata
uid?: string;
@@ -90,6 +112,12 @@ export interface CRDTExploreMapState {
// Comment data (text, username, timestamp)
commentData: Map<string, CommentData>;
// Post-it note collection (OR-Set for add/remove operations)
postItNotes: ORSet<string>; // Set of post-it note IDs
// Post-it note data (position, size, content, color)
postItNoteData: Map<string, CRDTPostItNoteData>;
// Panel collection (OR-Set for add/remove operations)
panels: ORSet<string>; // Set of panel IDs
@@ -138,6 +166,21 @@ export interface CRDTExploreMapStateJSON {
removes: string[];
};
commentData?: Record<string, CommentData>;
postItNotes?: {
adds: Record<string, string[]>;
removes: string[];
};
postItNoteData?: Record<string, {
id: string;
positionX: { value: number; timestamp: HLCTimestamp };
positionY: { value: number; timestamp: HLCTimestamp };
width: { value: number; timestamp: HLCTimestamp };
height: { value: number; timestamp: HLCTimestamp };
zIndex: { value: number; timestamp: HLCTimestamp };
text: { value: string; timestamp: HLCTimestamp };
color: { value: string; timestamp: HLCTimestamp };
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
}>;
panels: {
adds: Record<string, string[]>;
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;
/**
@@ -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,
@@ -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
*/