Add colour and emoji to frames
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
|
||||
import { useClickAway } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import {
|
||||
updateFramePosition,
|
||||
updateFrameSize,
|
||||
updateFrameTitle,
|
||||
updateFrameColor,
|
||||
updateFrameEmoji,
|
||||
setActiveFrameDrag,
|
||||
clearActiveFrameDrag,
|
||||
associatePanelWithFrame,
|
||||
@@ -25,6 +30,26 @@ interface ExploreMapFrameProps {
|
||||
frame: Frame;
|
||||
}
|
||||
|
||||
// Predefined color palette for frames
|
||||
const FRAME_COLORS = [
|
||||
{ name: 'Blue', value: '#6e9fff' },
|
||||
{ name: 'Green', value: '#73bf69' },
|
||||
{ name: 'Yellow', value: '#fade2a' },
|
||||
{ name: 'Orange', value: '#ff9830' },
|
||||
{ name: 'Red', value: '#f2495c' },
|
||||
{ name: 'Purple', value: '#b877d9' },
|
||||
{ name: 'Pink', value: '#fe85b4' },
|
||||
{ name: 'Cyan', value: '#5dc4cd' },
|
||||
{ name: 'Gray', value: '#9fa7b3' },
|
||||
];
|
||||
|
||||
// Predefined emoji options for frames
|
||||
const FRAME_EMOJIS = [
|
||||
'📦', '📊', '📈', '🎯', '🔥', '⭐', '💡', '🚀',
|
||||
'📝', '🎨', '🔧', '⚙️', '🏆', '🎪', '🌟', '💼',
|
||||
'📌', '🔍', '📱', '💻', '🖥️', '⚡', '🌈', '🎭',
|
||||
];
|
||||
|
||||
function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
@@ -36,6 +61,14 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
const [titleValue, setTitleValue] = useState(frame.title);
|
||||
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||
const colorButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [colorPickerPosition, setColorPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [emojiPickerPosition, setEmojiPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Track current size during resize for visual feedback
|
||||
const [currentSize, setCurrentSize] = useState({
|
||||
@@ -159,7 +192,7 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
// Update panel-frame associations based on current frame bounds
|
||||
// This should only handle NEW panels entering the frame, not existing associations
|
||||
const updatePanelAssociations = useCallback(
|
||||
(frameX: number, frameY: number, frameWidth: number, frameHeight: number, skipExisting: boolean = false) => {
|
||||
(frameX: number, frameY: number, frameWidth: number, frameHeight: number, skipExisting = false) => {
|
||||
for (const [panelId, panel] of Object.entries(allPanels)) {
|
||||
// Skip panels that are already associated with this frame if skipExisting is true
|
||||
// This prevents recalculating offsets for panels that moved with the frame
|
||||
@@ -367,6 +400,73 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
setShowDeleteDialog(false);
|
||||
}, []);
|
||||
|
||||
// Close pickers when clicking outside
|
||||
useClickAway(colorPickerRef, () => {
|
||||
setShowColorPicker(false);
|
||||
});
|
||||
|
||||
useClickAway(emojiPickerRef, () => {
|
||||
setShowEmojiPicker(false);
|
||||
});
|
||||
|
||||
const handleColorButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!showColorPicker && colorButtonRef.current) {
|
||||
const rect = colorButtonRef.current.getBoundingClientRect();
|
||||
setColorPickerPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
setShowColorPicker(!showColorPicker);
|
||||
setShowEmojiPicker(false);
|
||||
},
|
||||
[showColorPicker]
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
(color: string) => {
|
||||
dispatch(
|
||||
updateFrameColor({
|
||||
frameId: frame.id,
|
||||
color,
|
||||
})
|
||||
);
|
||||
setShowColorPicker(false);
|
||||
},
|
||||
[dispatch, frame.id]
|
||||
);
|
||||
|
||||
const handleEmojiButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!showEmojiPicker && emojiButtonRef.current) {
|
||||
const rect = emojiButtonRef.current.getBoundingClientRect();
|
||||
setEmojiPickerPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
setShowColorPicker(false);
|
||||
},
|
||||
[showEmojiPicker]
|
||||
);
|
||||
|
||||
const handleEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
dispatch(
|
||||
updateFrameEmoji({
|
||||
frameId: frame.id,
|
||||
emoji,
|
||||
})
|
||||
);
|
||||
setShowEmojiPicker(false);
|
||||
},
|
||||
[dispatch, frame.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
position={{ x: frame.position.x, y: frame.position.y }}
|
||||
@@ -393,6 +493,7 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
height: `${currentSize.height * viewport.zoom}px`,
|
||||
transform: `scale(${1 / viewport.zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
borderColor: frame.color || undefined,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -401,8 +502,20 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
top: `${-30 / viewport.zoom}px`,
|
||||
transform: `scale(${1 / viewport.zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
borderColor: frame.color || undefined,
|
||||
}}
|
||||
>
|
||||
{/* Emoji button */}
|
||||
<button
|
||||
ref={emojiButtonRef}
|
||||
className={styles.emojiButton}
|
||||
onClick={handleEmojiButtonClick}
|
||||
aria-label="Change emoji"
|
||||
>
|
||||
{frame.emoji || '🔍'}
|
||||
</button>
|
||||
|
||||
{/* Title display/input */}
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
className={styles.frameTitleInput}
|
||||
@@ -410,7 +523,6 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
@@ -418,6 +530,17 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
{frame.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color picker button */}
|
||||
<button
|
||||
ref={colorButtonRef}
|
||||
className={styles.colorButton}
|
||||
onClick={handleColorButtonClick}
|
||||
style={{ backgroundColor: frame.color || '#6e9fff' }}
|
||||
aria-label="Change frame color"
|
||||
/>
|
||||
|
||||
{/* Delete button */}
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
size="sm"
|
||||
@@ -436,6 +559,53 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
onClose={handleCloseDeleteDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Render pickers via portal to escape z-index stacking context */}
|
||||
{showEmojiPicker && emojiPickerPosition && ReactDOM.createPortal(
|
||||
<div
|
||||
ref={emojiPickerRef}
|
||||
className={styles.emojiPicker}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${emojiPickerPosition.top}px`,
|
||||
left: `${emojiPickerPosition.left}px`,
|
||||
}}
|
||||
>
|
||||
{FRAME_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className={cx(styles.emojiOption, frame.emoji === emoji && styles.emojiOptionSelected)}
|
||||
onClick={() => handleEmojiSelect(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showColorPicker && colorPickerPosition && ReactDOM.createPortal(
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className={styles.colorPicker}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${colorPickerPosition.top}px`,
|
||||
left: `${colorPickerPosition.left}px`,
|
||||
}}
|
||||
>
|
||||
{FRAME_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
className={cx(styles.colorOption, frame.color === color.value && styles.colorOptionSelected)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
onClick={() => handleColorSelect(color.value)}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</Rnd>
|
||||
);
|
||||
}
|
||||
@@ -485,6 +655,51 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
deleteButton: css({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
emojiButton: css({
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
padding: theme.spacing(0.25, 0.5),
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
},
|
||||
}),
|
||||
emojiPicker: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(8, 1fr)',
|
||||
gap: theme.spacing(0.5),
|
||||
padding: theme.spacing(1),
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
zIndex: 10000,
|
||||
minWidth: '240px',
|
||||
}),
|
||||
emojiOption: css({
|
||||
fontSize: '20px',
|
||||
padding: theme.spacing(0.5),
|
||||
border: `1px solid transparent`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
},
|
||||
}),
|
||||
emojiOptionSelected: css({
|
||||
border: `1px solid ${theme.colors.primary.border}`,
|
||||
backgroundColor: theme.colors.action.selected,
|
||||
}),
|
||||
frameTitle: css({
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
@@ -493,11 +708,47 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
frameTitleInput: css({
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
padding: 0,
|
||||
padding: theme.spacing(0.5),
|
||||
border: 'none',
|
||||
outline: `2px solid ${theme.colors.primary.border}`,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
minWidth: '100px',
|
||||
}),
|
||||
colorButton: css({
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
padding: 0,
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
}),
|
||||
colorPicker: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: theme.spacing(0.5),
|
||||
padding: theme.spacing(1),
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
zIndex: 10000,
|
||||
}),
|
||||
colorOption: css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
padding: 0,
|
||||
border: `2px solid transparent`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
}),
|
||||
colorOptionSelected: css({
|
||||
border: `2px solid ${theme.colors.text.primary}`,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
UpdateFramePositionOperation,
|
||||
UpdateFrameSizeOperation,
|
||||
UpdateFrameTitleOperation,
|
||||
UpdateFrameColorOperation,
|
||||
UpdateFrameEmojiOperation,
|
||||
AssociatePanelWithFrameOperation,
|
||||
DisassociatePanelFromFrameOperation,
|
||||
AddPostItOperation,
|
||||
@@ -201,6 +203,8 @@ export class CRDTStateManager {
|
||||
zIndex: data.zIndex.get(),
|
||||
},
|
||||
createdBy: data.createdBy.get(),
|
||||
color: data.color.get(),
|
||||
emoji: data.emoji.get(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
}
|
||||
@@ -475,7 +479,9 @@ export class CRDTStateManager {
|
||||
frameId: string,
|
||||
title: string,
|
||||
position: { x: number; y: number; width: number; height: number },
|
||||
createdBy?: string
|
||||
createdBy?: string,
|
||||
color?: string,
|
||||
emoji?: string
|
||||
): AddFrameOperation {
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
@@ -484,7 +490,7 @@ export class CRDTStateManager {
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, title, position, createdBy },
|
||||
payload: { frameId, title, position, createdBy, color, emoji },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -579,6 +585,50 @@ export class CRDTStateManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update frame color operation
|
||||
*/
|
||||
createUpdateFrameColorOperation(
|
||||
frameId: string,
|
||||
color: string | undefined
|
||||
): UpdateFrameColorOperation | null {
|
||||
if (!this.state.frames.contains(frameId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'update-frame-color',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, color },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update frame emoji operation
|
||||
*/
|
||||
createUpdateFrameEmojiOperation(
|
||||
frameId: string,
|
||||
emoji: string | undefined
|
||||
): UpdateFrameEmojiOperation | null {
|
||||
if (!this.state.frames.contains(frameId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'update-frame-emoji',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, emoji },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an associate panel with frame operation
|
||||
*/
|
||||
@@ -928,6 +978,10 @@ export class CRDTStateManager {
|
||||
return this.applyUpdateFrameSize(operation);
|
||||
case 'update-frame-title':
|
||||
return this.applyUpdateFrameTitle(operation);
|
||||
case 'update-frame-color':
|
||||
return this.applyUpdateFrameColor(operation);
|
||||
case 'update-frame-emoji':
|
||||
return this.applyUpdateFrameEmoji(operation);
|
||||
case 'associate-panel-with-frame':
|
||||
return this.applyAssociatePanelWithFrame(operation);
|
||||
case 'disassociate-panel-from-frame':
|
||||
@@ -948,12 +1002,15 @@ export class CRDTStateManager {
|
||||
return this.applyUpdatePostItColor(operation);
|
||||
case 'batch':
|
||||
return this.applyBatchOperation(operation);
|
||||
default:
|
||||
default: {
|
||||
// TypeScript exhaustiveness check - this should never happen at runtime
|
||||
const exhaustiveCheck: never = operation;
|
||||
return {
|
||||
success: false,
|
||||
applied: false,
|
||||
error: `Unknown operation type: ${(operation as CRDTOperation).type}`,
|
||||
error: `Unknown operation type: ${(exhaustiveCheck as { type: string }).type}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -1309,7 +1366,7 @@ export class CRDTStateManager {
|
||||
}
|
||||
|
||||
private applyAddFrame(operation: AddFrameOperation): OperationResult {
|
||||
const { frameId, title, position, createdBy } = operation.payload;
|
||||
const { frameId, title, position, createdBy, color, emoji } = operation.payload;
|
||||
|
||||
// Add to OR-Set with operation ID as tag
|
||||
this.state.frames.add(frameId, operation.operationId);
|
||||
@@ -1328,6 +1385,8 @@ export class CRDTStateManager {
|
||||
width: new LWWRegister(position.width, operation.timestamp),
|
||||
height: new LWWRegister(position.height, operation.timestamp),
|
||||
zIndex: new LWWRegister(zIndex, operation.timestamp),
|
||||
color: new LWWRegister(color, operation.timestamp),
|
||||
emoji: new LWWRegister(emoji, operation.timestamp),
|
||||
createdBy: new LWWRegister(createdBy, operation.timestamp),
|
||||
remoteVersion: 0,
|
||||
});
|
||||
@@ -1402,6 +1461,32 @@ export class CRDTStateManager {
|
||||
return { success: true, applied: updated };
|
||||
}
|
||||
|
||||
private applyUpdateFrameColor(operation: UpdateFrameColorOperation): OperationResult {
|
||||
const { frameId, color } = operation.payload;
|
||||
const frameData = this.state.frameData.get(frameId);
|
||||
|
||||
if (!frameData) {
|
||||
return { success: true, applied: false, error: 'Frame not found' };
|
||||
}
|
||||
|
||||
const updated = frameData.color.set(color, operation.timestamp);
|
||||
|
||||
return { success: true, applied: updated };
|
||||
}
|
||||
|
||||
private applyUpdateFrameEmoji(operation: UpdateFrameEmojiOperation): OperationResult {
|
||||
const { frameId, emoji } = operation.payload;
|
||||
const frameData = this.state.frameData.get(frameId);
|
||||
|
||||
if (!frameData) {
|
||||
return { success: true, applied: false, error: 'Frame not found' };
|
||||
}
|
||||
|
||||
const updated = frameData.emoji.set(emoji, operation.timestamp);
|
||||
|
||||
return { success: true, applied: updated };
|
||||
}
|
||||
|
||||
private applyAssociatePanelWithFrame(operation: AssociatePanelWithFrameOperation): OperationResult {
|
||||
const { panelId, frameId, offsetX, offsetY } = operation.payload;
|
||||
const panelData = this.state.panelData.get(panelId);
|
||||
@@ -1542,6 +1627,8 @@ export class CRDTStateManager {
|
||||
width: otherFrameData.width.clone(),
|
||||
height: otherFrameData.height.clone(),
|
||||
zIndex: otherFrameData.zIndex.clone(),
|
||||
color: otherFrameData.color.clone(),
|
||||
emoji: otherFrameData.emoji.clone(),
|
||||
createdBy: otherFrameData.createdBy.clone(),
|
||||
remoteVersion: otherFrameData.remoteVersion,
|
||||
});
|
||||
@@ -1553,6 +1640,8 @@ export class CRDTStateManager {
|
||||
myFrameData.width.merge(otherFrameData.width);
|
||||
myFrameData.height.merge(otherFrameData.height);
|
||||
myFrameData.zIndex.merge(otherFrameData.zIndex);
|
||||
myFrameData.color.merge(otherFrameData.color);
|
||||
myFrameData.emoji.merge(otherFrameData.emoji);
|
||||
myFrameData.createdBy.merge(otherFrameData.createdBy);
|
||||
}
|
||||
}
|
||||
@@ -1594,7 +1683,7 @@ export class CRDTStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
const frameData: Record<string, any> = {};
|
||||
const frameData: CRDTExploreMapStateJSON['frameData'] = {};
|
||||
for (const [frameId, data] of this.state.frameData.entries()) {
|
||||
if (this.state.frames.contains(frameId)) {
|
||||
frameData[frameId] = {
|
||||
@@ -1605,6 +1694,8 @@ export class CRDTStateManager {
|
||||
width: data.width.toJSON(),
|
||||
height: data.height.toJSON(),
|
||||
zIndex: data.zIndex.toJSON(),
|
||||
color: data.color.toJSON(),
|
||||
emoji: data.emoji.toJSON(),
|
||||
createdBy: data.createdBy.toJSON(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
@@ -1715,6 +1806,8 @@ export class CRDTStateManager {
|
||||
width: LWWRegister.fromJSON(data.width),
|
||||
height: LWWRegister.fromJSON(data.height),
|
||||
zIndex: LWWRegister.fromJSON(data.zIndex),
|
||||
color: data.color ? LWWRegister.fromJSON(data.color) : new LWWRegister(undefined, defaultTimestamp),
|
||||
emoji: data.emoji ? LWWRegister.fromJSON(data.emoji) : new LWWRegister(undefined, defaultTimestamp),
|
||||
createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp),
|
||||
remoteVersion: data.remoteVersion || 0,
|
||||
});
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface CRDTFrameData {
|
||||
width: LWWRegister<number>;
|
||||
height: LWWRegister<number>;
|
||||
zIndex: LWWRegister<number>;
|
||||
color: LWWRegister<string | undefined>;
|
||||
emoji: LWWRegister<string | undefined>;
|
||||
|
||||
// Creator metadata (username of who created the frame)
|
||||
createdBy: LWWRegister<string | undefined>;
|
||||
@@ -214,6 +216,8 @@ export interface CRDTExploreMapStateJSON {
|
||||
width: { value: number; timestamp: HLCTimestamp };
|
||||
height: { value: number; timestamp: HLCTimestamp };
|
||||
zIndex: { value: number; timestamp: HLCTimestamp };
|
||||
color?: { value: string | undefined; timestamp: HLCTimestamp };
|
||||
emoji?: { value: string | undefined; timestamp: HLCTimestamp };
|
||||
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
|
||||
remoteVersion?: number;
|
||||
}>;
|
||||
@@ -242,6 +246,8 @@ export type CRDTOperationType =
|
||||
| 'update-frame-position'
|
||||
| 'update-frame-size'
|
||||
| 'update-frame-title'
|
||||
| 'update-frame-color'
|
||||
| 'update-frame-emoji'
|
||||
| 'associate-panel-with-frame'
|
||||
| 'disassociate-panel-from-frame'
|
||||
| 'add-postit'
|
||||
@@ -399,6 +405,8 @@ export interface AddFrameOperation extends CRDTOperationBase {
|
||||
height: number;
|
||||
};
|
||||
createdBy?: string;
|
||||
color?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -450,6 +458,28 @@ export interface UpdateFrameTitleOperation extends CRDTOperationBase {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frame color operation
|
||||
*/
|
||||
export interface UpdateFrameColorOperation extends CRDTOperationBase {
|
||||
type: 'update-frame-color';
|
||||
payload: {
|
||||
frameId: string;
|
||||
color: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frame emoji operation
|
||||
*/
|
||||
export interface UpdateFrameEmojiOperation extends CRDTOperationBase {
|
||||
type: 'update-frame-emoji';
|
||||
payload: {
|
||||
frameId: string;
|
||||
emoji: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Associate panel with frame operation
|
||||
*/
|
||||
@@ -592,6 +622,8 @@ export type CRDTOperation =
|
||||
| UpdateFramePositionOperation
|
||||
| UpdateFrameSizeOperation
|
||||
| UpdateFrameTitleOperation
|
||||
| UpdateFrameColorOperation
|
||||
| UpdateFrameEmojiOperation
|
||||
| AssociatePanelWithFrameOperation
|
||||
| DisassociatePanelFromFrameOperation
|
||||
| AddPostItOperation
|
||||
|
||||
@@ -767,6 +767,8 @@ const crdtSlice = createSlice({
|
||||
position?: { x: number; y: number; width: number; height: number };
|
||||
title?: string;
|
||||
createdBy?: string;
|
||||
color?: string;
|
||||
emoji?: string;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
@@ -775,12 +777,16 @@ const crdtSlice = createSlice({
|
||||
const position = action.payload.position || {
|
||||
x: 200, y: 200, width: 800, height: 600
|
||||
};
|
||||
const color = action.payload.color || '#6e9fff'; // Default blue color
|
||||
const emoji = action.payload.emoji || '🔍'; // Default magnifying glass emoji
|
||||
|
||||
const operation = manager.createAddFrameOperation(
|
||||
frameId,
|
||||
title,
|
||||
position,
|
||||
action.payload.createdBy
|
||||
action.payload.createdBy,
|
||||
color,
|
||||
emoji
|
||||
);
|
||||
|
||||
manager.applyOperation(operation);
|
||||
@@ -852,7 +858,7 @@ const crdtSlice = createSlice({
|
||||
}
|
||||
|
||||
// Create operations for all child panels
|
||||
const panelOps: any[] = [];
|
||||
const panelOps: CRDTOperation[] = [];
|
||||
const childPanelIds = manager.getPanelsInFrame(action.payload.frameId);
|
||||
|
||||
for (const panelId of childPanelIds) {
|
||||
@@ -930,6 +936,54 @@ const crdtSlice = createSlice({
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update frame color
|
||||
*/
|
||||
updateFrameColor: (state, action: PayloadAction<{
|
||||
frameId: string;
|
||||
color: string | undefined;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const operation = manager.createUpdateFrameColorOperation(
|
||||
action.payload.frameId,
|
||||
action.payload.color
|
||||
);
|
||||
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update frame emoji
|
||||
*/
|
||||
updateFrameEmoji: (state, action: PayloadAction<{
|
||||
frameId: string;
|
||||
emoji: string | undefined;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const operation = manager.createUpdateFrameEmojiOperation(
|
||||
action.payload.frameId,
|
||||
action.payload.emoji
|
||||
);
|
||||
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Associate panel with frame
|
||||
*/
|
||||
@@ -1130,6 +1184,8 @@ export const {
|
||||
updateFramePosition,
|
||||
updateFrameSize,
|
||||
updateFrameTitle,
|
||||
updateFrameColor,
|
||||
updateFrameEmoji,
|
||||
associatePanelWithFrame,
|
||||
disassociatePanelFromFrame,
|
||||
clearPendingOperations,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ExploreMapFrame {
|
||||
position: PanelPosition; // Reuse position type
|
||||
createdBy?: string;
|
||||
remoteVersion?: number;
|
||||
color?: string; // Frame border color (hex color string)
|
||||
emoji?: string; // Emoji to display on frame
|
||||
}
|
||||
|
||||
export interface CanvasViewport {
|
||||
|
||||
Reference in New Issue
Block a user