Fix sticky note conflicts with frames
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user