Add colour and emoji to frames
This commit is contained in:
@@ -1,15 +1,20 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState, useRef } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
|
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
|
||||||
|
import { useClickAway } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'app/types/store';
|
import { useDispatch, useSelector } from 'app/types/store';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
updateFramePosition,
|
updateFramePosition,
|
||||||
updateFrameSize,
|
updateFrameSize,
|
||||||
updateFrameTitle,
|
updateFrameTitle,
|
||||||
|
updateFrameColor,
|
||||||
|
updateFrameEmoji,
|
||||||
setActiveFrameDrag,
|
setActiveFrameDrag,
|
||||||
clearActiveFrameDrag,
|
clearActiveFrameDrag,
|
||||||
associatePanelWithFrame,
|
associatePanelWithFrame,
|
||||||
@@ -25,6 +30,26 @@ interface ExploreMapFrameProps {
|
|||||||
frame: Frame;
|
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) {
|
function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -36,6 +61,14 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
const [titleValue, setTitleValue] = useState(frame.title);
|
const [titleValue, setTitleValue] = useState(frame.title);
|
||||||
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
|
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
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
|
// Track current size during resize for visual feedback
|
||||||
const [currentSize, setCurrentSize] = useState({
|
const [currentSize, setCurrentSize] = useState({
|
||||||
@@ -159,7 +192,7 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
// Update panel-frame associations based on current frame bounds
|
// Update panel-frame associations based on current frame bounds
|
||||||
// This should only handle NEW panels entering the frame, not existing associations
|
// This should only handle NEW panels entering the frame, not existing associations
|
||||||
const updatePanelAssociations = useCallback(
|
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)) {
|
for (const [panelId, panel] of Object.entries(allPanels)) {
|
||||||
// Skip panels that are already associated with this frame if skipExisting is true
|
// Skip panels that are already associated with this frame if skipExisting is true
|
||||||
// This prevents recalculating offsets for panels that moved with the frame
|
// This prevents recalculating offsets for panels that moved with the frame
|
||||||
@@ -367,6 +400,73 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
setShowDeleteDialog(false);
|
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 (
|
return (
|
||||||
<Rnd
|
<Rnd
|
||||||
position={{ x: frame.position.x, y: frame.position.y }}
|
position={{ x: frame.position.x, y: frame.position.y }}
|
||||||
@@ -393,6 +493,7 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
height: `${currentSize.height * viewport.zoom}px`,
|
height: `${currentSize.height * viewport.zoom}px`,
|
||||||
transform: `scale(${1 / viewport.zoom})`,
|
transform: `scale(${1 / viewport.zoom})`,
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
|
borderColor: frame.color || undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -401,8 +502,20 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
top: `${-30 / viewport.zoom}px`,
|
top: `${-30 / viewport.zoom}px`,
|
||||||
transform: `scale(${1 / viewport.zoom})`,
|
transform: `scale(${1 / viewport.zoom})`,
|
||||||
transformOrigin: 'top left',
|
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 ? (
|
{isEditingTitle ? (
|
||||||
<input
|
<input
|
||||||
className={styles.frameTitleInput}
|
className={styles.frameTitleInput}
|
||||||
@@ -410,7 +523,6 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
onChange={handleTitleChange}
|
onChange={handleTitleChange}
|
||||||
onBlur={handleTitleBlur}
|
onBlur={handleTitleBlur}
|
||||||
onKeyDown={handleTitleKeyDown}
|
onKeyDown={handleTitleKeyDown}
|
||||||
autoFocus
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -418,6 +530,17 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
{frame.title}
|
{frame.title}
|
||||||
</div>
|
</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
|
<IconButton
|
||||||
name="trash-alt"
|
name="trash-alt"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -436,6 +559,53 @@ function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
|||||||
onClose={handleCloseDeleteDialog}
|
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>
|
</Rnd>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -485,6 +655,51 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
deleteButton: css({
|
deleteButton: css({
|
||||||
cursor: 'pointer',
|
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({
|
frameTitle: css({
|
||||||
fontSize: theme.typography.body.fontSize,
|
fontSize: theme.typography.body.fontSize,
|
||||||
fontWeight: theme.typography.fontWeightMedium,
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
@@ -493,11 +708,47 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
frameTitleInput: css({
|
frameTitleInput: css({
|
||||||
fontSize: theme.typography.body.fontSize,
|
fontSize: theme.typography.body.fontSize,
|
||||||
fontWeight: theme.typography.fontWeightMedium,
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
padding: 0,
|
padding: theme.spacing(0.5),
|
||||||
border: 'none',
|
border: 'none',
|
||||||
outline: `2px solid ${theme.colors.primary.border}`,
|
outline: `2px solid ${theme.colors.primary.border}`,
|
||||||
backgroundColor: theme.colors.background.primary,
|
backgroundColor: theme.colors.background.primary,
|
||||||
borderRadius: theme.shape.radius.default,
|
borderRadius: theme.shape.radius.default,
|
||||||
minWidth: '100px',
|
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,
|
UpdateFramePositionOperation,
|
||||||
UpdateFrameSizeOperation,
|
UpdateFrameSizeOperation,
|
||||||
UpdateFrameTitleOperation,
|
UpdateFrameTitleOperation,
|
||||||
|
UpdateFrameColorOperation,
|
||||||
|
UpdateFrameEmojiOperation,
|
||||||
AssociatePanelWithFrameOperation,
|
AssociatePanelWithFrameOperation,
|
||||||
DisassociatePanelFromFrameOperation,
|
DisassociatePanelFromFrameOperation,
|
||||||
AddPostItOperation,
|
AddPostItOperation,
|
||||||
@@ -201,6 +203,8 @@ export class CRDTStateManager {
|
|||||||
zIndex: data.zIndex.get(),
|
zIndex: data.zIndex.get(),
|
||||||
},
|
},
|
||||||
createdBy: data.createdBy.get(),
|
createdBy: data.createdBy.get(),
|
||||||
|
color: data.color.get(),
|
||||||
|
emoji: data.emoji.get(),
|
||||||
remoteVersion: data.remoteVersion,
|
remoteVersion: data.remoteVersion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -475,7 +479,9 @@ export class CRDTStateManager {
|
|||||||
frameId: string,
|
frameId: string,
|
||||||
title: string,
|
title: string,
|
||||||
position: { x: number; y: number; width: number; height: number },
|
position: { x: number; y: number; width: number; height: number },
|
||||||
createdBy?: string
|
createdBy?: string,
|
||||||
|
color?: string,
|
||||||
|
emoji?: string
|
||||||
): AddFrameOperation {
|
): AddFrameOperation {
|
||||||
const timestamp = this.clock.tick();
|
const timestamp = this.clock.tick();
|
||||||
return {
|
return {
|
||||||
@@ -484,7 +490,7 @@ export class CRDTStateManager {
|
|||||||
operationId: uuidv4(),
|
operationId: uuidv4(),
|
||||||
timestamp,
|
timestamp,
|
||||||
nodeId: this.nodeId,
|
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
|
* Create an associate panel with frame operation
|
||||||
*/
|
*/
|
||||||
@@ -928,6 +978,10 @@ export class CRDTStateManager {
|
|||||||
return this.applyUpdateFrameSize(operation);
|
return this.applyUpdateFrameSize(operation);
|
||||||
case 'update-frame-title':
|
case 'update-frame-title':
|
||||||
return this.applyUpdateFrameTitle(operation);
|
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':
|
case 'associate-panel-with-frame':
|
||||||
return this.applyAssociatePanelWithFrame(operation);
|
return this.applyAssociatePanelWithFrame(operation);
|
||||||
case 'disassociate-panel-from-frame':
|
case 'disassociate-panel-from-frame':
|
||||||
@@ -948,12 +1002,15 @@ export class CRDTStateManager {
|
|||||||
return this.applyUpdatePostItColor(operation);
|
return this.applyUpdatePostItColor(operation);
|
||||||
case 'batch':
|
case 'batch':
|
||||||
return this.applyBatchOperation(operation);
|
return this.applyBatchOperation(operation);
|
||||||
default:
|
default: {
|
||||||
|
// TypeScript exhaustiveness check - this should never happen at runtime
|
||||||
|
const exhaustiveCheck: never = operation;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
applied: false,
|
applied: false,
|
||||||
error: `Unknown operation type: ${(operation as CRDTOperation).type}`,
|
error: `Unknown operation type: ${(exhaustiveCheck as { type: string }).type}`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -1309,7 +1366,7 @@ export class CRDTStateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyAddFrame(operation: AddFrameOperation): OperationResult {
|
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
|
// Add to OR-Set with operation ID as tag
|
||||||
this.state.frames.add(frameId, operation.operationId);
|
this.state.frames.add(frameId, operation.operationId);
|
||||||
@@ -1328,6 +1385,8 @@ export class CRDTStateManager {
|
|||||||
width: new LWWRegister(position.width, operation.timestamp),
|
width: new LWWRegister(position.width, operation.timestamp),
|
||||||
height: new LWWRegister(position.height, operation.timestamp),
|
height: new LWWRegister(position.height, operation.timestamp),
|
||||||
zIndex: new LWWRegister(zIndex, 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),
|
createdBy: new LWWRegister(createdBy, operation.timestamp),
|
||||||
remoteVersion: 0,
|
remoteVersion: 0,
|
||||||
});
|
});
|
||||||
@@ -1402,6 +1461,32 @@ export class CRDTStateManager {
|
|||||||
return { success: true, applied: updated };
|
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 {
|
private applyAssociatePanelWithFrame(operation: AssociatePanelWithFrameOperation): OperationResult {
|
||||||
const { panelId, frameId, offsetX, offsetY } = operation.payload;
|
const { panelId, frameId, offsetX, offsetY } = operation.payload;
|
||||||
const panelData = this.state.panelData.get(panelId);
|
const panelData = this.state.panelData.get(panelId);
|
||||||
@@ -1542,6 +1627,8 @@ export class CRDTStateManager {
|
|||||||
width: otherFrameData.width.clone(),
|
width: otherFrameData.width.clone(),
|
||||||
height: otherFrameData.height.clone(),
|
height: otherFrameData.height.clone(),
|
||||||
zIndex: otherFrameData.zIndex.clone(),
|
zIndex: otherFrameData.zIndex.clone(),
|
||||||
|
color: otherFrameData.color.clone(),
|
||||||
|
emoji: otherFrameData.emoji.clone(),
|
||||||
createdBy: otherFrameData.createdBy.clone(),
|
createdBy: otherFrameData.createdBy.clone(),
|
||||||
remoteVersion: otherFrameData.remoteVersion,
|
remoteVersion: otherFrameData.remoteVersion,
|
||||||
});
|
});
|
||||||
@@ -1553,6 +1640,8 @@ export class CRDTStateManager {
|
|||||||
myFrameData.width.merge(otherFrameData.width);
|
myFrameData.width.merge(otherFrameData.width);
|
||||||
myFrameData.height.merge(otherFrameData.height);
|
myFrameData.height.merge(otherFrameData.height);
|
||||||
myFrameData.zIndex.merge(otherFrameData.zIndex);
|
myFrameData.zIndex.merge(otherFrameData.zIndex);
|
||||||
|
myFrameData.color.merge(otherFrameData.color);
|
||||||
|
myFrameData.emoji.merge(otherFrameData.emoji);
|
||||||
myFrameData.createdBy.merge(otherFrameData.createdBy);
|
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()) {
|
for (const [frameId, data] of this.state.frameData.entries()) {
|
||||||
if (this.state.frames.contains(frameId)) {
|
if (this.state.frames.contains(frameId)) {
|
||||||
frameData[frameId] = {
|
frameData[frameId] = {
|
||||||
@@ -1605,6 +1694,8 @@ export class CRDTStateManager {
|
|||||||
width: data.width.toJSON(),
|
width: data.width.toJSON(),
|
||||||
height: data.height.toJSON(),
|
height: data.height.toJSON(),
|
||||||
zIndex: data.zIndex.toJSON(),
|
zIndex: data.zIndex.toJSON(),
|
||||||
|
color: data.color.toJSON(),
|
||||||
|
emoji: data.emoji.toJSON(),
|
||||||
createdBy: data.createdBy.toJSON(),
|
createdBy: data.createdBy.toJSON(),
|
||||||
remoteVersion: data.remoteVersion,
|
remoteVersion: data.remoteVersion,
|
||||||
};
|
};
|
||||||
@@ -1715,6 +1806,8 @@ export class CRDTStateManager {
|
|||||||
width: LWWRegister.fromJSON(data.width),
|
width: LWWRegister.fromJSON(data.width),
|
||||||
height: LWWRegister.fromJSON(data.height),
|
height: LWWRegister.fromJSON(data.height),
|
||||||
zIndex: LWWRegister.fromJSON(data.zIndex),
|
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),
|
createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp),
|
||||||
remoteVersion: data.remoteVersion || 0,
|
remoteVersion: data.remoteVersion || 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface CRDTFrameData {
|
|||||||
width: LWWRegister<number>;
|
width: LWWRegister<number>;
|
||||||
height: LWWRegister<number>;
|
height: LWWRegister<number>;
|
||||||
zIndex: LWWRegister<number>;
|
zIndex: LWWRegister<number>;
|
||||||
|
color: LWWRegister<string | undefined>;
|
||||||
|
emoji: LWWRegister<string | undefined>;
|
||||||
|
|
||||||
// Creator metadata (username of who created the frame)
|
// Creator metadata (username of who created the frame)
|
||||||
createdBy: LWWRegister<string | undefined>;
|
createdBy: LWWRegister<string | undefined>;
|
||||||
@@ -214,6 +216,8 @@ export interface CRDTExploreMapStateJSON {
|
|||||||
width: { value: number; timestamp: HLCTimestamp };
|
width: { value: number; timestamp: HLCTimestamp };
|
||||||
height: { value: number; timestamp: HLCTimestamp };
|
height: { value: number; timestamp: HLCTimestamp };
|
||||||
zIndex: { 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 };
|
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
|
||||||
remoteVersion?: number;
|
remoteVersion?: number;
|
||||||
}>;
|
}>;
|
||||||
@@ -242,6 +246,8 @@ export type CRDTOperationType =
|
|||||||
| 'update-frame-position'
|
| 'update-frame-position'
|
||||||
| 'update-frame-size'
|
| 'update-frame-size'
|
||||||
| 'update-frame-title'
|
| 'update-frame-title'
|
||||||
|
| 'update-frame-color'
|
||||||
|
| 'update-frame-emoji'
|
||||||
| 'associate-panel-with-frame'
|
| 'associate-panel-with-frame'
|
||||||
| 'disassociate-panel-from-frame'
|
| 'disassociate-panel-from-frame'
|
||||||
| 'add-postit'
|
| 'add-postit'
|
||||||
@@ -399,6 +405,8 @@ export interface AddFrameOperation extends CRDTOperationBase {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
createdBy?: string;
|
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
|
* Associate panel with frame operation
|
||||||
*/
|
*/
|
||||||
@@ -592,6 +622,8 @@ export type CRDTOperation =
|
|||||||
| UpdateFramePositionOperation
|
| UpdateFramePositionOperation
|
||||||
| UpdateFrameSizeOperation
|
| UpdateFrameSizeOperation
|
||||||
| UpdateFrameTitleOperation
|
| UpdateFrameTitleOperation
|
||||||
|
| UpdateFrameColorOperation
|
||||||
|
| UpdateFrameEmojiOperation
|
||||||
| AssociatePanelWithFrameOperation
|
| AssociatePanelWithFrameOperation
|
||||||
| DisassociatePanelFromFrameOperation
|
| DisassociatePanelFromFrameOperation
|
||||||
| AddPostItOperation
|
| AddPostItOperation
|
||||||
|
|||||||
@@ -767,6 +767,8 @@ const crdtSlice = createSlice({
|
|||||||
position?: { x: number; y: number; width: number; height: number };
|
position?: { x: number; y: number; width: number; height: number };
|
||||||
title?: string;
|
title?: string;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
color?: string;
|
||||||
|
emoji?: string;
|
||||||
}>) => {
|
}>) => {
|
||||||
const manager = getCRDTManager(state);
|
const manager = getCRDTManager(state);
|
||||||
|
|
||||||
@@ -775,12 +777,16 @@ const crdtSlice = createSlice({
|
|||||||
const position = action.payload.position || {
|
const position = action.payload.position || {
|
||||||
x: 200, y: 200, width: 800, height: 600
|
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(
|
const operation = manager.createAddFrameOperation(
|
||||||
frameId,
|
frameId,
|
||||||
title,
|
title,
|
||||||
position,
|
position,
|
||||||
action.payload.createdBy
|
action.payload.createdBy,
|
||||||
|
color,
|
||||||
|
emoji
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.applyOperation(operation);
|
manager.applyOperation(operation);
|
||||||
@@ -852,7 +858,7 @@ const crdtSlice = createSlice({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create operations for all child panels
|
// Create operations for all child panels
|
||||||
const panelOps: any[] = [];
|
const panelOps: CRDTOperation[] = [];
|
||||||
const childPanelIds = manager.getPanelsInFrame(action.payload.frameId);
|
const childPanelIds = manager.getPanelsInFrame(action.payload.frameId);
|
||||||
|
|
||||||
for (const panelId of childPanelIds) {
|
for (const panelId of childPanelIds) {
|
||||||
@@ -930,6 +936,54 @@ const crdtSlice = createSlice({
|
|||||||
state.pendingOperations.push(operation);
|
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
|
* Associate panel with frame
|
||||||
*/
|
*/
|
||||||
@@ -1130,6 +1184,8 @@ export const {
|
|||||||
updateFramePosition,
|
updateFramePosition,
|
||||||
updateFrameSize,
|
updateFrameSize,
|
||||||
updateFrameTitle,
|
updateFrameTitle,
|
||||||
|
updateFrameColor,
|
||||||
|
updateFrameEmoji,
|
||||||
associatePanelWithFrame,
|
associatePanelWithFrame,
|
||||||
disassociatePanelFromFrame,
|
disassociatePanelFromFrame,
|
||||||
clearPendingOperations,
|
clearPendingOperations,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface ExploreMapFrame {
|
|||||||
position: PanelPosition; // Reuse position type
|
position: PanelPosition; // Reuse position type
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
remoteVersion?: number;
|
remoteVersion?: number;
|
||||||
|
color?: string; // Frame border color (hex color string)
|
||||||
|
emoji?: string; // Emoji to display on frame
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasViewport {
|
export interface CanvasViewport {
|
||||||
|
|||||||
Reference in New Issue
Block a user