Comments
This commit is contained in:
@@ -8,14 +8,16 @@ import (
|
||||
type OperationType string
|
||||
|
||||
const (
|
||||
OpAddPanel OperationType = "add-panel"
|
||||
OpRemovePanel OperationType = "remove-panel"
|
||||
OpUpdatePanelPosition OperationType = "update-panel-position"
|
||||
OpUpdatePanelSize OperationType = "update-panel-size"
|
||||
OpUpdatePanelZIndex OperationType = "update-panel-zindex"
|
||||
OpUpdatePanelExplore OperationType = "update-panel-explore-state"
|
||||
OpUpdateTitle OperationType = "update-title"
|
||||
OpBatch OperationType = "batch"
|
||||
OpAddPanel OperationType = "add-panel"
|
||||
OpRemovePanel OperationType = "remove-panel"
|
||||
OpUpdatePanelPosition OperationType = "update-panel-position"
|
||||
OpUpdatePanelSize OperationType = "update-panel-size"
|
||||
OpUpdatePanelZIndex OperationType = "update-panel-zindex"
|
||||
OpUpdatePanelExplore OperationType = "update-panel-explore-state"
|
||||
OpUpdateTitle OperationType = "update-title"
|
||||
OpAddComment OperationType = "add-comment"
|
||||
OpRemoveComment OperationType = "remove-comment"
|
||||
OpBatch OperationType = "batch"
|
||||
)
|
||||
|
||||
// Operation represents a CRDT operation
|
||||
@@ -72,6 +74,25 @@ type UpdateTitlePayload struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// CommentData represents a comment with text, username, and timestamp
|
||||
type CommentData struct {
|
||||
Text string `json:"text"`
|
||||
Username string `json:"username"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// AddCommentPayload represents the payload for add-comment operation
|
||||
type AddCommentPayload struct {
|
||||
CommentID string `json:"commentId"`
|
||||
Comment CommentData `json:"comment"`
|
||||
}
|
||||
|
||||
// RemoveCommentPayload represents the payload for remove-comment operation
|
||||
type RemoveCommentPayload struct {
|
||||
CommentID string `json:"commentId"`
|
||||
ObservedTags []string `json:"observedTags"`
|
||||
}
|
||||
|
||||
// BatchPayload represents the payload for batch operation
|
||||
type BatchPayload struct {
|
||||
Operations []Operation `json:"operations"`
|
||||
@@ -123,6 +144,16 @@ func (op *Operation) ParsePayload() (interface{}, error) {
|
||||
err := json.Unmarshal(op.Payload, &payload)
|
||||
return payload, err
|
||||
|
||||
case OpAddComment:
|
||||
var payload AddCommentPayload
|
||||
err := json.Unmarshal(op.Payload, &payload)
|
||||
return payload, err
|
||||
|
||||
case OpRemoveComment:
|
||||
var payload RemoveCommentPayload
|
||||
err := json.Unmarshal(op.Payload, &payload)
|
||||
return payload, err
|
||||
|
||||
case OpBatch:
|
||||
var payload BatchPayload
|
||||
err := json.Unmarshal(op.Payload, &payload)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCursorSync } from '../hooks/useCursorSync';
|
||||
import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice';
|
||||
import { selectPanels, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors';
|
||||
|
||||
import { ExploreMapComment } from './ExploreMapComment';
|
||||
import { ExploreMapPanelContainer } from './ExploreMapPanelContainer';
|
||||
import { UserCursor } from './UserCursor';
|
||||
|
||||
@@ -271,6 +272,7 @@ export function ExploreMapCanvas() {
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
<ExploreMapComment />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { dateTime, GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Button, ConfirmModal, Icon, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { CommentData } from '../crdt/types';
|
||||
import { addComment, removeComment } from '../state/crdtSlice';
|
||||
import { selectComments } from '../state/selectors';
|
||||
|
||||
export function ExploreMapComment() {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
const comments = useSelector((state) => selectComments(state.exploreMapCRDT));
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [commentValue, setCommentValue] = useState('');
|
||||
const [commentToDelete, setCommentToDelete] = useState<string | null>(null);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const commentsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && textAreaRef.current) {
|
||||
textAreaRef.current.focus();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
// Auto-scroll to bottom when new comments are added
|
||||
useEffect(() => {
|
||||
if (commentsEndRef.current) {
|
||||
commentsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [comments.length]);
|
||||
|
||||
const handleAddCommentClick = useCallback(() => {
|
||||
setEditing(true);
|
||||
setIsCollapsed(false); // Expand when adding a comment
|
||||
}, []);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
setIsCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleHeaderKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleHeaderClick();
|
||||
}
|
||||
},
|
||||
[handleHeaderClick]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmedText = commentValue.trim();
|
||||
|
||||
if (trimmedText) {
|
||||
const commentData: CommentData = {
|
||||
text: trimmedText,
|
||||
username: contextSrv.user.name || contextSrv.user.login || 'Unknown',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch(addComment({ comment: commentData }));
|
||||
setCommentValue('');
|
||||
}
|
||||
setEditing(false);
|
||||
}, [dispatch, commentValue]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setCommentValue('');
|
||||
setEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleCommentKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
// Allow Ctrl/Cmd+Enter to save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[handleCancel, handleSave]
|
||||
);
|
||||
|
||||
const formatTimestamp = useCallback((timestamp: number) => {
|
||||
if (!timestamp) {return '';}
|
||||
const dt = dateTime(timestamp);
|
||||
const now = dateTime();
|
||||
const diff = now.diff(dt, 'minutes');
|
||||
|
||||
if (diff < 1) {
|
||||
return t('explore-map.comment.just-now', 'Just now');
|
||||
} else if (diff < 60) {
|
||||
return t('explore-map.comment.minutes-ago', '{{minutes}}m ago', { minutes: Math.floor(diff) });
|
||||
} else if (diff < 1440) {
|
||||
return t('explore-map.comment.hours-ago', '{{hours}}h ago', { hours: Math.floor(diff / 60) });
|
||||
} else {
|
||||
return dt.format('MMM D, YYYY HH:mm');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveCommentClick = useCallback(
|
||||
(commentId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCommentToDelete(commentId);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
if (commentToDelete) {
|
||||
dispatch(removeComment({ commentId: commentToDelete }));
|
||||
setCommentToDelete(null);
|
||||
}
|
||||
}, [dispatch, commentToDelete]);
|
||||
|
||||
const handleCancelDelete = useCallback(() => {
|
||||
setCommentToDelete(null);
|
||||
}, []);
|
||||
|
||||
const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown';
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={styles.commentContainer}>
|
||||
<div className={styles.commentEditor}>
|
||||
<TextArea
|
||||
ref={textAreaRef}
|
||||
value={commentValue}
|
||||
onChange={(e) => setCommentValue(e.currentTarget.value)}
|
||||
onKeyDown={handleCommentKeyDown}
|
||||
placeholder={t('explore-map.comment.placeholder', 'Add a comment...')}
|
||||
rows={3}
|
||||
className={styles.commentTextArea}
|
||||
/>
|
||||
<div className={styles.editorActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
icon="times"
|
||||
>
|
||||
{t('explore-map.comment.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
icon="check"
|
||||
>
|
||||
{t('explore-map.comment.save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.commentHint}>
|
||||
{t('explore-map.comment.hint', 'Press Ctrl+Enter to save, Esc to cancel')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.commentContainer}>
|
||||
<div
|
||||
className={cx(styles.commentsHeader, isCollapsed && styles.commentsHeaderCollapsed)}
|
||||
onClick={handleHeaderClick}
|
||||
onKeyDown={handleHeaderKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon name="comment-alt" className={styles.headerIcon} />
|
||||
<span className={styles.headerText}>
|
||||
{t('explore-map.comment.comments', 'Comments')} ({comments.length})
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon="plus"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddCommentClick();
|
||||
}}
|
||||
className={styles.addButton}
|
||||
>
|
||||
{t('explore-map.comment.add', 'Add')}
|
||||
</Button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className={styles.commentsList}>
|
||||
{comments.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Icon name="comment-alt" className={styles.emptyIcon} />
|
||||
<span className={styles.emptyText}>
|
||||
{t('explore-map.comment.no-comments', 'No comments yet. Be the first to comment!')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
comments.map(({ id, data }) => (
|
||||
<div key={id} className={styles.commentItem}>
|
||||
<div className={styles.commentContent}>
|
||||
<span className={styles.commentText}>{data.text}</span>
|
||||
<div className={styles.commentMeta}>
|
||||
<span className={styles.commentUsername}>{data.username}</span>
|
||||
{data.timestamp && (
|
||||
<span className={styles.commentTimestamp}>
|
||||
{formatTimestamp(data.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.commentActions}>
|
||||
{data.username === currentUsername && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fill="text"
|
||||
icon="trash-alt"
|
||||
onClick={(e) => handleRemoveCommentClick(id, e)}
|
||||
className={styles.deleteButton}
|
||||
tooltip={t('explore-map.comment.delete', 'Delete comment')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={commentsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={commentToDelete !== null}
|
||||
title={t('explore-map.comment.delete-title', 'Delete comment')}
|
||||
body={t('explore-map.comment.delete-body', 'Are you sure you want to delete this comment? This action cannot be undone.')}
|
||||
confirmText={t('explore-map.comment.delete-confirm', 'Delete')}
|
||||
dismissText={t('explore-map.comment.delete-cancel', 'Cancel')}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onDismiss={handleCancelDelete}
|
||||
icon="exclamation-triangle"
|
||||
confirmButtonVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
commentContainer: css({
|
||||
position: 'fixed',
|
||||
bottom: theme.spacing(3),
|
||||
right: theme.spacing(3),
|
||||
width: '300px',
|
||||
maxHeight: '500px',
|
||||
zIndex: 1001,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
}),
|
||||
commentsHeader: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1.5),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
cursor: 'pointer',
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: 'background-color 0.2s',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
},
|
||||
}),
|
||||
commentsHeaderCollapsed: css({
|
||||
borderBottom: 'none',
|
||||
}),
|
||||
headerIcon: css({
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
headerText: css({
|
||||
flex: 1,
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
color: theme.colors.text.primary,
|
||||
}),
|
||||
addButton: css({
|
||||
flexShrink: 0,
|
||||
}),
|
||||
commentsList: css({
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
maxHeight: '400px',
|
||||
padding: theme.spacing(1),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
emptyState: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(3),
|
||||
textAlign: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
emptyIcon: css({
|
||||
fontSize: theme.spacing(4),
|
||||
marginBottom: theme.spacing(1),
|
||||
opacity: 0.5,
|
||||
}),
|
||||
emptyText: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
commentItem: css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1.5),
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: 'background-color 0.2s',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.background.canvas,
|
||||
},
|
||||
}),
|
||||
commentContent: css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
commentText: css({
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
color: theme.colors.text.primary,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: theme.typography.body.lineHeight,
|
||||
}),
|
||||
commentMeta: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
marginTop: theme.spacing(0.5),
|
||||
}),
|
||||
commentUsername: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
commentTimestamp: css({
|
||||
color: theme.colors.text.disabled,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
}),
|
||||
commentActions: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
flexShrink: 0,
|
||||
}),
|
||||
deleteButton: css({
|
||||
opacity: 0.6,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: 'opacity 0.2s',
|
||||
},
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
color: theme.colors.error.text,
|
||||
},
|
||||
}),
|
||||
commentEditor: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(1.5),
|
||||
}),
|
||||
commentTextArea: css({
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
minHeight: '60px',
|
||||
marginBottom: theme.spacing(1),
|
||||
}),
|
||||
editorActions: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.spacing(1),
|
||||
marginTop: theme.spacing(1),
|
||||
}),
|
||||
commentHint: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
color: theme.colors.text.secondary,
|
||||
fontStyle: 'italic',
|
||||
marginTop: theme.spacing(0.5),
|
||||
textAlign: 'right',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -34,6 +34,9 @@ export type {
|
||||
UpdatePanelZIndexOperation,
|
||||
UpdatePanelExploreStateOperation,
|
||||
UpdateTitleOperation,
|
||||
AddCommentOperation,
|
||||
RemoveCommentOperation,
|
||||
BatchOperation,
|
||||
OperationResult,
|
||||
CommentData,
|
||||
} from './types';
|
||||
|
||||
@@ -25,8 +25,11 @@ import {
|
||||
UpdatePanelExploreStateOperation,
|
||||
UpdatePanelIframeUrlOperation,
|
||||
UpdateTitleOperation,
|
||||
AddCommentOperation,
|
||||
RemoveCommentOperation,
|
||||
OperationResult,
|
||||
CRDTExploreMapStateJSON,
|
||||
CommentData,
|
||||
} from './types';
|
||||
|
||||
export class CRDTStateManager {
|
||||
@@ -48,6 +51,8 @@ export class CRDTStateManager {
|
||||
return {
|
||||
uid: this.mapUid,
|
||||
title: createLWWRegister('Untitled Map', this.nodeId),
|
||||
comments: new ORSet<string>(),
|
||||
commentData: new Map(),
|
||||
panels: new ORSet<string>(),
|
||||
panelData: new Map(),
|
||||
zIndexCounter: new PNCounter(),
|
||||
@@ -328,6 +333,76 @@ export class CRDTStateManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an add comment operation
|
||||
*/
|
||||
createAddCommentOperation(commentId: string, comment: CommentData): AddCommentOperation {
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'add-comment',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: {
|
||||
commentId,
|
||||
comment,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remove comment operation
|
||||
*/
|
||||
createRemoveCommentOperation(commentId: string): RemoveCommentOperation {
|
||||
const timestamp = this.clock.tick();
|
||||
const observedTags = this.state.comments.getTags(commentId);
|
||||
return {
|
||||
type: 'remove-comment',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: {
|
||||
commentId,
|
||||
observedTags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all comment IDs
|
||||
*/
|
||||
getCommentIds(): string[] {
|
||||
return this.state.comments.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comment data by ID
|
||||
*/
|
||||
getCommentData(commentId: string): CommentData | undefined {
|
||||
if (!this.state.comments.contains(commentId)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.state.commentData.get(commentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all comments as an array, sorted by timestamp (newest first)
|
||||
*/
|
||||
getCommentsForUI(): Array<{ id: string; data: CommentData }> {
|
||||
const commentIds = this.getCommentIds();
|
||||
const comments = commentIds
|
||||
.map((id) => {
|
||||
const data = this.getCommentData(id);
|
||||
return data ? { id, data } : null;
|
||||
})
|
||||
.filter((c): c is { id: string; data: CommentData } => c !== null);
|
||||
|
||||
// Sort by timestamp, newest first
|
||||
return comments.sort((a, b) => b.data.timestamp - a.data.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a CRDT operation to the state
|
||||
*/
|
||||
@@ -353,6 +428,10 @@ export class CRDTStateManager {
|
||||
return this.applyUpdatePanelIframeUrl(operation);
|
||||
case 'update-title':
|
||||
return this.applyUpdateTitle(operation);
|
||||
case 'add-comment':
|
||||
return this.applyAddComment(operation);
|
||||
case 'remove-comment':
|
||||
return this.applyRemoveComment(operation);
|
||||
case 'batch':
|
||||
return this.applyBatchOperation(operation);
|
||||
default:
|
||||
@@ -509,6 +588,41 @@ export class CRDTStateManager {
|
||||
};
|
||||
}
|
||||
|
||||
private applyAddComment(operation: AddCommentOperation): OperationResult {
|
||||
const { commentId, comment } = operation.payload;
|
||||
|
||||
// Add to OR-Set using operation ID as the tag
|
||||
this.state.comments.add(commentId, operation.operationId);
|
||||
|
||||
// Store comment data
|
||||
this.state.commentData.set(commentId, comment);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
applied: true,
|
||||
};
|
||||
}
|
||||
|
||||
private applyRemoveComment(operation: RemoveCommentOperation): OperationResult {
|
||||
const { commentId, observedTags } = operation.payload;
|
||||
|
||||
// Check if comment exists before removing
|
||||
const existed = this.state.comments.contains(commentId);
|
||||
|
||||
// Remove from OR-Set
|
||||
this.state.comments.remove(commentId, observedTags);
|
||||
|
||||
// Remove comment data if it existed
|
||||
if (existed) {
|
||||
this.state.commentData.delete(commentId);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
applied: existed,
|
||||
};
|
||||
}
|
||||
|
||||
private applyBatchOperation(operation: any): OperationResult {
|
||||
let anyApplied = false;
|
||||
const errors: string[] = [];
|
||||
@@ -537,6 +651,16 @@ export class CRDTStateManager {
|
||||
// Merge title
|
||||
this.state.title.merge(other.title);
|
||||
|
||||
// Merge comments OR-Set
|
||||
this.state.comments.merge(other.comments);
|
||||
|
||||
// Merge comment data
|
||||
for (const [commentId, commentData] of other.commentData.entries()) {
|
||||
if (this.state.comments.contains(commentId)) {
|
||||
this.state.commentData.set(commentId, commentData);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge panel OR-Set
|
||||
this.state.panels.merge(other.panels);
|
||||
|
||||
@@ -598,9 +722,18 @@ export class CRDTStateManager {
|
||||
};
|
||||
}
|
||||
|
||||
const commentData: Record<string, CommentData> = {};
|
||||
for (const [commentId, data] of this.state.commentData.entries()) {
|
||||
if (this.state.comments.contains(commentId)) {
|
||||
commentData[commentId] = data;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uid: this.state.uid,
|
||||
title: this.state.title.toJSON(),
|
||||
comments: this.state.comments.toJSON(),
|
||||
commentData,
|
||||
panels: this.state.panels.toJSON(),
|
||||
panelData,
|
||||
zIndexCounter: this.state.zIndexCounter.toJSON(),
|
||||
@@ -615,6 +748,13 @@ export class CRDTStateManager {
|
||||
|
||||
manager.state.uid = json.uid;
|
||||
manager.state.title = LWWRegister.fromJSON(json.title);
|
||||
manager.state.comments = json.comments ? ORSet.fromJSON(json.comments) : new ORSet<string>();
|
||||
manager.state.commentData = new Map();
|
||||
if (json.commentData) {
|
||||
for (const [commentId, data] of Object.entries(json.commentData)) {
|
||||
manager.state.commentData.set(commentId, data);
|
||||
}
|
||||
}
|
||||
manager.state.panels = ORSet.fromJSON(json.panels);
|
||||
manager.state.zIndexCounter = PNCounter.fromJSON(json.zIndexCounter);
|
||||
|
||||
|
||||
@@ -43,11 +43,23 @@ export interface CRDTPanelData {
|
||||
/**
|
||||
* Complete CRDT-based Explore Map state
|
||||
*/
|
||||
export interface CommentData {
|
||||
text: string;
|
||||
username: string;
|
||||
timestamp: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
export interface CRDTExploreMapState {
|
||||
// Map metadata
|
||||
uid?: string;
|
||||
title: LWWRegister<string>;
|
||||
|
||||
// Comment collection (OR-Set for add/remove operations)
|
||||
comments: ORSet<string>; // Set of comment IDs
|
||||
|
||||
// Comment data (text, username, timestamp)
|
||||
commentData: Map<string, CommentData>;
|
||||
|
||||
// Panel collection (OR-Set for add/remove operations)
|
||||
panels: ORSet<string>; // Set of panel IDs
|
||||
|
||||
@@ -85,6 +97,11 @@ export interface CRDTExploreMapStateJSON {
|
||||
value: string;
|
||||
timestamp: HLCTimestamp;
|
||||
};
|
||||
comments?: {
|
||||
adds: Record<string, string[]>;
|
||||
removes: string[];
|
||||
};
|
||||
commentData?: Record<string, CommentData>;
|
||||
panels: {
|
||||
adds: Record<string, string[]>;
|
||||
removes: string[];
|
||||
@@ -120,6 +137,8 @@ export type CRDTOperationType =
|
||||
| 'update-panel-explore-state'
|
||||
| 'update-panel-iframe-url'
|
||||
| 'update-title'
|
||||
| 'add-comment'
|
||||
| 'remove-comment'
|
||||
| 'batch'; // For batching multiple operations
|
||||
|
||||
/**
|
||||
@@ -229,6 +248,28 @@ export interface UpdateTitleOperation extends CRDTOperationBase {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add comment operation
|
||||
*/
|
||||
export interface AddCommentOperation extends CRDTOperationBase {
|
||||
type: 'add-comment';
|
||||
payload: {
|
||||
commentId: string;
|
||||
comment: CommentData;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comment operation
|
||||
*/
|
||||
export interface RemoveCommentOperation extends CRDTOperationBase {
|
||||
type: 'remove-comment';
|
||||
payload: {
|
||||
commentId: string;
|
||||
observedTags: string[]; // Tags from OR-Set
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch operation (multiple operations in one)
|
||||
*/
|
||||
@@ -251,6 +292,8 @@ export type CRDTOperation =
|
||||
| UpdatePanelExploreStateOperation
|
||||
| UpdatePanelIframeUrlOperation
|
||||
| UpdateTitleOperation
|
||||
| AddCommentOperation
|
||||
| RemoveCommentOperation
|
||||
| BatchOperation;
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { HLCTimestamp } from '../crdt/hlc';
|
||||
import {
|
||||
CRDTOperation,
|
||||
@@ -16,7 +17,10 @@ import {
|
||||
UpdatePanelZIndexOperation,
|
||||
UpdatePanelExploreStateOperation,
|
||||
UpdateTitleOperation,
|
||||
AddCommentOperation,
|
||||
RemoveCommentOperation,
|
||||
BatchOperation,
|
||||
CommentData,
|
||||
} from '../crdt/types';
|
||||
import { SerializedExploreState } from '../state/types';
|
||||
|
||||
@@ -181,6 +185,50 @@ export function createUpdateTitleOperation(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an add comment operation
|
||||
*/
|
||||
export function createAddCommentOperation(
|
||||
mapUid: string,
|
||||
nodeId: string,
|
||||
timestamp: HLCTimestamp,
|
||||
payload: {
|
||||
commentId: string;
|
||||
comment: CommentData;
|
||||
}
|
||||
): AddCommentOperation {
|
||||
return {
|
||||
type: 'add-comment',
|
||||
mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remove comment operation
|
||||
*/
|
||||
export function createRemoveCommentOperation(
|
||||
mapUid: string,
|
||||
nodeId: string,
|
||||
timestamp: HLCTimestamp,
|
||||
payload: {
|
||||
commentId: string;
|
||||
observedTags: string[];
|
||||
}
|
||||
): RemoveCommentOperation {
|
||||
return {
|
||||
type: 'remove-comment',
|
||||
mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a batch operation containing multiple sub-operations
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,8 @@ export {
|
||||
createUpdatePanelZIndexOperation,
|
||||
createUpdatePanelExploreStateOperation,
|
||||
createUpdateTitleOperation,
|
||||
createAddCommentOperation,
|
||||
createRemoveCommentOperation,
|
||||
createBatchOperation,
|
||||
createMultiPanelMoveOperation,
|
||||
createDuplicatePanelOperation,
|
||||
|
||||
@@ -145,6 +145,20 @@ function compressPayload(operation: CRDTOperation): any {
|
||||
t: payload.title,
|
||||
};
|
||||
|
||||
case 'add-comment':
|
||||
return {
|
||||
cid: payload.commentId,
|
||||
ct: payload.comment.text,
|
||||
cu: payload.comment.username,
|
||||
cts: payload.comment.timestamp,
|
||||
};
|
||||
|
||||
case 'remove-comment':
|
||||
return {
|
||||
cid: payload.commentId,
|
||||
ot: payload.observedTags,
|
||||
};
|
||||
|
||||
case 'batch':
|
||||
return {
|
||||
ops: payload.operations.map(compressOperation),
|
||||
@@ -234,6 +248,22 @@ function decompressPayload(type: string, compressed: any): any {
|
||||
title: compressed.t,
|
||||
};
|
||||
|
||||
case 'add-comment':
|
||||
return {
|
||||
commentId: compressed.cid,
|
||||
comment: {
|
||||
text: compressed.ct,
|
||||
username: compressed.cu,
|
||||
timestamp: compressed.cts,
|
||||
},
|
||||
};
|
||||
|
||||
case 'remove-comment':
|
||||
return {
|
||||
commentId: compressed.cid,
|
||||
observedTags: compressed.ot,
|
||||
};
|
||||
|
||||
case 'batch':
|
||||
return {
|
||||
operations: compressed.ops.map(decompressOperation),
|
||||
@@ -260,7 +290,7 @@ export function estimateOperationSize(operation: CRDTOperation): number {
|
||||
*/
|
||||
export function batchOperations(
|
||||
operations: CRDTOperation[],
|
||||
maxBatchSize: number = 10
|
||||
maxBatchSize = 10
|
||||
): CRDTOperation[][] {
|
||||
const batches: CRDTOperation[][] = [];
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ export function validateOperation(
|
||||
case 'update-title':
|
||||
validateUpdateTitle(operation, opts, errors);
|
||||
break;
|
||||
case 'add-comment':
|
||||
validateAddComment(operation, opts, errors);
|
||||
break;
|
||||
case 'remove-comment':
|
||||
validateRemoveComment(operation, errors);
|
||||
break;
|
||||
case 'batch':
|
||||
validateBatchOperation(operation, opts, errors);
|
||||
break;
|
||||
@@ -269,6 +275,58 @@ function validateUpdateTitle(operation: any, opts: Required<ValidationOptions>,
|
||||
}
|
||||
}
|
||||
|
||||
function validateAddComment(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
|
||||
if (!operation.payload) {
|
||||
errors.push('Add comment operation requires payload');
|
||||
return;
|
||||
}
|
||||
|
||||
const { commentId, comment } = operation.payload;
|
||||
|
||||
if (!commentId || typeof commentId !== 'string') {
|
||||
errors.push('Comment ID is required and must be a string');
|
||||
}
|
||||
|
||||
if (!comment || typeof comment !== 'object') {
|
||||
errors.push('Comment must be an object');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof comment.text !== 'string') {
|
||||
errors.push('Comment text must be a string');
|
||||
}
|
||||
if (typeof comment.username !== 'string') {
|
||||
errors.push('Comment username must be a string');
|
||||
}
|
||||
if (typeof comment.timestamp !== 'number') {
|
||||
errors.push('Comment timestamp must be a number');
|
||||
}
|
||||
|
||||
// Comments can be longer than titles, so we use a higher limit
|
||||
// Using 10x the title limit for comments
|
||||
const maxCommentLength = opts.maxTitleLength * 10;
|
||||
if (comment.text && comment.text.length > maxCommentLength) {
|
||||
errors.push(`Comment text exceeds maximum length of ${maxCommentLength} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRemoveComment(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
|
||||
if (!operation.payload) {
|
||||
errors.push('Remove comment operation requires payload');
|
||||
return;
|
||||
}
|
||||
|
||||
const { commentId, observedTags } = operation.payload;
|
||||
|
||||
if (!commentId || typeof commentId !== 'string') {
|
||||
errors.push('Comment ID is required and must be a string');
|
||||
}
|
||||
|
||||
if (!Array.isArray(observedTags)) {
|
||||
errors.push('Observed tags must be an array');
|
||||
}
|
||||
}
|
||||
|
||||
function validateBatchOperation(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
|
||||
if (!operation.payload) {
|
||||
errors.push('Batch operation requires payload');
|
||||
|
||||
@@ -11,7 +11,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { generateExploreId } from 'app/core/utils/explore';
|
||||
|
||||
import { CRDTStateManager } from '../crdt/state';
|
||||
import { CRDTOperation } from '../crdt/types';
|
||||
import { CRDTOperation, CommentData } from '../crdt/types';
|
||||
|
||||
import { CanvasViewport, SerializedExploreState, UserCursor } from './types';
|
||||
|
||||
@@ -455,6 +455,33 @@ const crdtSlice = createSlice({
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a comment
|
||||
*/
|
||||
addComment: (state, action: PayloadAction<{ comment: CommentData }>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
const commentId = uuidv4();
|
||||
|
||||
const operation = manager.createAddCommentOperation(commentId, action.payload.comment);
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a comment
|
||||
*/
|
||||
removeComment: (state, action: PayloadAction<{ commentId: string }>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const operation = manager.createRemoveCommentOperation(action.payload.commentId);
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Duplicate a panel
|
||||
*/
|
||||
@@ -610,6 +637,8 @@ export const {
|
||||
savePanelExploreState,
|
||||
updatePanelIframeUrl,
|
||||
updateMapTitle,
|
||||
addComment,
|
||||
removeComment,
|
||||
duplicatePanel,
|
||||
clearPendingOperations,
|
||||
updateViewport,
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
*/
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { CRDTStateManager } from '../crdt/state';
|
||||
|
||||
import { ExploreMapCRDTState } from './crdtSlice';
|
||||
import { ExploreMapPanel } from './types';
|
||||
|
||||
@@ -98,6 +100,17 @@ export const selectMapTitle = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Select all comments as an array, sorted by timestamp (newest first)
|
||||
*/
|
||||
export const selectComments = createSelector(
|
||||
[(state: ExploreMapCRDTState) => state],
|
||||
(state) => {
|
||||
const manager = getCRDTManager(state);
|
||||
return manager.getCommentsForUI();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Select map UID
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user