Add colour and emoji to frames

This commit is contained in:
Christian Simon
2025-12-04 13:11:02 +00:00
parent 4e2849f1a9
commit fe8a7c052a
5 changed files with 447 additions and 13 deletions
@@ -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}`,
}),
});
+99 -6
View File
@@ -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 {