Add frames
This commit is contained in:
+9
-14
@@ -11,20 +11,15 @@ export const DebugAssistantContext: React.FC = () => {
|
||||
const pageContext = usePageContext();
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.group('🔍 Assistant Debug Info');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Current URL:', window.location.pathname);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Registered Components:', Object.keys(pageComponents));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Component Details:', pageComponents);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Page Context Items:', pageContext.length);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Page Context:', pageContext);
|
||||
// eslint-disable-next-line no-console
|
||||
console.groupEnd();
|
||||
// Debug logging disabled to reduce console noise
|
||||
// Uncomment if needed for assistant debugging
|
||||
// console.group('🔍 Assistant Debug Info');
|
||||
// console.log('Current URL:', window.location.pathname);
|
||||
// console.log('Registered Components:', Object.keys(pageComponents));
|
||||
// console.log('Component Details:', pageComponents);
|
||||
// console.log('Page Context Items:', pageContext.length);
|
||||
// console.log('Page Context:', pageContext);
|
||||
// console.groupEnd();
|
||||
}, [pageComponents, pageContext]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Checkbox, ConfirmModal } from '@grafana/ui';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { splitClose } from '../../explore/state/main';
|
||||
import { removeFrame } from '../state/crdtSlice';
|
||||
import { selectPanelsInFrame, selectPanels } from '../state/selectors';
|
||||
|
||||
interface ConfirmDeleteFrameDialogProps {
|
||||
frameId: string;
|
||||
frameTitle: string;
|
||||
panelCount: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDeleteFrameDialog({ frameId, frameTitle, panelCount, onClose }: ConfirmDeleteFrameDialogProps) {
|
||||
const dispatch = useDispatch();
|
||||
const panelsInFrame = useSelector((state) => selectPanelsInFrame(state.exploreMapCRDT, frameId));
|
||||
const allPanels = useSelector((state) => selectPanels(state.exploreMapCRDT));
|
||||
const [deletePanels, setDeletePanels] = useState(false);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
// If deleting panels, clean up Explore state first
|
||||
if (deletePanels) {
|
||||
for (const panelId of panelsInFrame) {
|
||||
const panel = allPanels[panelId];
|
||||
if (panel) {
|
||||
dispatch(splitClose(panel.exploreId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(
|
||||
removeFrame({
|
||||
frameId,
|
||||
deletePanels,
|
||||
})
|
||||
);
|
||||
onClose();
|
||||
}, [dispatch, frameId, deletePanels, onClose, panelsInFrame, allPanels]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title="Delete frame"
|
||||
body={
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to delete the frame "{frameTitle}"?
|
||||
</p>
|
||||
{panelCount > 0 && (
|
||||
<>
|
||||
<p>
|
||||
This frame contains {panelCount} panel{panelCount > 1 ? 's' : ''}.
|
||||
</p>
|
||||
<Checkbox
|
||||
label="Also delete all panels in this frame"
|
||||
value={deletePanels}
|
||||
onChange={(e) => setDeletePanels(e.currentTarget.checked)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
confirmText="Delete"
|
||||
onConfirm={handleConfirm}
|
||||
onDismiss={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import { useTransformContext } from '../context/TransformContext';
|
||||
import { useCursorSync } from '../hooks/useCursorSync';
|
||||
import { useCursorViewportTracking } from '../hooks/useCursorViewportTracking';
|
||||
import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice';
|
||||
import { selectPanels, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors';
|
||||
import { selectPanels, selectFrames, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors';
|
||||
|
||||
import { EdgeCursorIndicator } from './EdgeCursorIndicator';
|
||||
import { ExploreMapComment } from './ExploreMapComment';
|
||||
import { ExploreMapFrame } from './ExploreMapFrame';
|
||||
import { ExploreMapPanelContainer } from './ExploreMapPanelContainer';
|
||||
import { UserCursor } from './UserCursor';
|
||||
|
||||
@@ -36,6 +37,7 @@ export function ExploreMapCanvas() {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
|
||||
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
|
||||
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
|
||||
const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT));
|
||||
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
|
||||
@@ -322,6 +324,11 @@ export function ExploreMapCanvas() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Render frames first (lower z-index) */}
|
||||
{Object.values(frames).map((frame) => (
|
||||
<ExploreMapFrame key={frame.id} frame={frame} />
|
||||
))}
|
||||
{/* Render panels on top */}
|
||||
{Object.values(panels).map((panel) => {
|
||||
return <ExploreMapPanelContainer key={panel.id} panel={panel} />;
|
||||
})}
|
||||
|
||||
@@ -13,8 +13,8 @@ import prometheusLogoSvg from 'app/plugins/datasource/prometheus/img/prometheus_
|
||||
import tempoLogoSvg from 'app/plugins/datasource/tempo/img/tempo_logo.svg';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { addPanel } from '../state/crdtSlice';
|
||||
import { selectPanels, selectMapUid } from '../state/selectors';
|
||||
import { addPanel, addFrame } from '../state/crdtSlice';
|
||||
import { selectPanels, selectMapUid, selectViewport, selectSelectedPanelIds } from '../state/selectors';
|
||||
import { AddPanelAction } from './AssistantComponents';
|
||||
|
||||
export function ExploreMapFloatingToolbar() {
|
||||
@@ -29,6 +29,8 @@ export function ExploreMapFloatingToolbar() {
|
||||
// Get canvas state for assistant context
|
||||
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
|
||||
const mapUid = useSelector((state) => selectMapUid(state.exploreMapCRDT));
|
||||
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
|
||||
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
|
||||
|
||||
const handleAddPanel = useCallback(() => {
|
||||
dispatch(
|
||||
@@ -99,6 +101,61 @@ export function ExploreMapFloatingToolbar() {
|
||||
setIsOpen(false);
|
||||
}, [dispatch, currentUsername]);
|
||||
|
||||
const handleAddFrame = useCallback(() => {
|
||||
// Get selected panels that are not already in a frame
|
||||
const selectedUnframedPanels = selectedPanelIds
|
||||
.map((id) => panels[id])
|
||||
.filter((panel) => panel && !panel.frameId);
|
||||
|
||||
let position: { x: number; y: number; width: number; height: number };
|
||||
|
||||
if (selectedUnframedPanels.length > 0) {
|
||||
// Calculate bounds around selected unframed panels
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const panel of selectedUnframedPanels) {
|
||||
minX = Math.min(minX, panel.position.x);
|
||||
minY = Math.min(minY, panel.position.y);
|
||||
maxX = Math.max(maxX, panel.position.x + panel.position.width);
|
||||
maxY = Math.max(maxY, panel.position.y + panel.position.height);
|
||||
}
|
||||
|
||||
// Add padding around the panels
|
||||
const padding = 50;
|
||||
position = {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
width: maxX - minX + padding * 2,
|
||||
height: maxY - minY + padding * 2,
|
||||
};
|
||||
} else {
|
||||
// No selected panels, position at viewport center
|
||||
const viewportSize = { width: window.innerWidth, height: window.innerHeight };
|
||||
const canvasCenterX = (-viewport.panX + viewportSize.width / 2) / viewport.zoom;
|
||||
const canvasCenterY = (-viewport.panY + viewportSize.height / 2) / viewport.zoom;
|
||||
|
||||
const frameWidth = 800;
|
||||
const frameHeight = 600;
|
||||
|
||||
position = {
|
||||
x: canvasCenterX - frameWidth / 2,
|
||||
y: canvasCenterY - frameHeight / 2,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
};
|
||||
}
|
||||
|
||||
dispatch(
|
||||
addFrame({
|
||||
position,
|
||||
createdBy: currentUsername,
|
||||
})
|
||||
);
|
||||
}, [dispatch, currentUsername, selectedPanelIds, panels, viewport]);
|
||||
|
||||
// Build context for assistant
|
||||
const canvasContext = useMemo(() => {
|
||||
const panelsArray = Object.values(panels);
|
||||
@@ -245,6 +302,9 @@ CRITICAL:
|
||||
/>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
<Button icon="folder-plus" onClick={handleAddFrame} variant="secondary">
|
||||
<Trans i18nKey="explore-map.toolbar.add-frame">Add frame</Trans>
|
||||
</Button>
|
||||
{isAssistantAvailable && Object.keys(panels).length > 0 && (
|
||||
<Button icon="ai-sparkle" onClick={handleOpenAssistant} variant="secondary">
|
||||
<Trans i18nKey="explore-map.toolbar.ask-assistant">Ask Assistant</Trans>
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import {
|
||||
updateFramePosition,
|
||||
updateFrameSize,
|
||||
updateFrameTitle,
|
||||
setActiveFrameDrag,
|
||||
clearActiveFrameDrag,
|
||||
associatePanelWithFrame,
|
||||
disassociatePanelFromFrame,
|
||||
removeFrame,
|
||||
} from '../state/crdtSlice';
|
||||
import { selectViewport, selectPanelsInFrame, selectPanels, selectFrames } from '../state/selectors';
|
||||
import { ExploreMapFrame as Frame } from '../state/types';
|
||||
|
||||
import { ConfirmDeleteFrameDialog } from './ConfirmDeleteFrameDialog';
|
||||
|
||||
interface ExploreMapFrameProps {
|
||||
frame: Frame;
|
||||
}
|
||||
|
||||
function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
|
||||
const panelsInFrame = useSelector((state) => selectPanelsInFrame(state.exploreMapCRDT, frame.id));
|
||||
const allPanels = useSelector((state) => selectPanels(state.exploreMapCRDT));
|
||||
const allFrames = useSelector((state) => selectFrames(state.exploreMapCRDT));
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleValue, setTitleValue] = useState(frame.title);
|
||||
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Track current size during resize for visual feedback
|
||||
const [currentSize, setCurrentSize] = useState({
|
||||
width: frame.position.width,
|
||||
height: frame.position.height,
|
||||
});
|
||||
|
||||
// Sync currentSize when frame position changes (from remote updates or after resize completes)
|
||||
React.useEffect(() => {
|
||||
setCurrentSize({
|
||||
width: frame.position.width,
|
||||
height: frame.position.height,
|
||||
});
|
||||
}, [frame.position.width, frame.position.height]);
|
||||
|
||||
// Check if two rectangles overlap
|
||||
const checkRectOverlap = useCallback(
|
||||
(
|
||||
x1: number,
|
||||
y1: number,
|
||||
w1: number,
|
||||
h1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
w2: number,
|
||||
h2: number
|
||||
): boolean => {
|
||||
return !(x1 + w1 <= x2 || x2 + w2 <= x1 || y1 + h1 <= y2 || y2 + h2 <= y1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Check if a proposed position would cause overlap with other frames
|
||||
const wouldOverlapOtherFrames = useCallback(
|
||||
(x: number, y: number, width: number, height: number): boolean => {
|
||||
for (const [frameId, otherFrame] of Object.entries(allFrames)) {
|
||||
// Skip the current frame
|
||||
if (frameId === frame.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
checkRectOverlap(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
otherFrame.position.x,
|
||||
otherFrame.position.y,
|
||||
otherFrame.position.width,
|
||||
otherFrame.position.height
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[allFrames, frame.id, checkRectOverlap]
|
||||
);
|
||||
|
||||
// Constrain position to avoid overlaps with other frames
|
||||
const constrainPosition = useCallback(
|
||||
(proposedX: number, proposedY: number, width: number, height: number): { x: number; y: number } => {
|
||||
// If no overlap, use the proposed position
|
||||
if (!wouldOverlapOtherFrames(proposedX, proposedY, width, height)) {
|
||||
return { x: proposedX, y: proposedY };
|
||||
}
|
||||
|
||||
// Try to find the nearest valid position by checking nearby positions
|
||||
// We'll use the last valid position if we can't find one
|
||||
return {
|
||||
x: frame.position.x,
|
||||
y: frame.position.y,
|
||||
};
|
||||
},
|
||||
[frame.position.x, frame.position.y, wouldOverlapOtherFrames]
|
||||
);
|
||||
|
||||
// Check if a panel is >50% inside the frame bounds
|
||||
const isPanelInFrame = useCallback(
|
||||
(
|
||||
panelX: number,
|
||||
panelY: number,
|
||||
panelWidth: number,
|
||||
panelHeight: number,
|
||||
frameX: number,
|
||||
frameY: number,
|
||||
frameWidth: number,
|
||||
frameHeight: number
|
||||
): boolean => {
|
||||
const panelLeft = panelX;
|
||||
const panelRight = panelX + panelWidth;
|
||||
const panelTop = panelY;
|
||||
const panelBottom = panelY + panelHeight;
|
||||
|
||||
const frameLeft = frameX;
|
||||
const frameRight = frameX + frameWidth;
|
||||
const frameTop = frameY;
|
||||
const frameBottom = frameY + frameHeight;
|
||||
|
||||
// Calculate intersection area
|
||||
const intersectLeft = Math.max(panelLeft, frameLeft);
|
||||
const intersectRight = Math.min(panelRight, frameRight);
|
||||
const intersectTop = Math.max(panelTop, frameTop);
|
||||
const intersectBottom = Math.min(panelBottom, frameBottom);
|
||||
|
||||
if (intersectRight > intersectLeft && intersectBottom > intersectTop) {
|
||||
const intersectArea = (intersectRight - intersectLeft) * (intersectBottom - intersectTop);
|
||||
const panelArea = panelWidth * panelHeight;
|
||||
|
||||
// If >50% of panel is inside frame, consider it contained
|
||||
return intersectArea / panelArea > 0.5;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
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
|
||||
if (skipExisting && panel.frameId === frame.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isInside = isPanelInFrame(
|
||||
panel.position.x,
|
||||
panel.position.y,
|
||||
panel.position.width,
|
||||
panel.position.height,
|
||||
frameX,
|
||||
frameY,
|
||||
frameWidth,
|
||||
frameHeight
|
||||
);
|
||||
|
||||
if (isInside && panel.frameId !== frame.id) {
|
||||
// Panel moved into this frame
|
||||
const offsetX = panel.position.x - frameX;
|
||||
const offsetY = panel.position.y - frameY;
|
||||
|
||||
dispatch(
|
||||
associatePanelWithFrame({
|
||||
panelId,
|
||||
frameId: frame.id,
|
||||
offsetX,
|
||||
offsetY,
|
||||
})
|
||||
);
|
||||
} else if (!isInside && panel.frameId === frame.id) {
|
||||
// Panel moved out of this frame
|
||||
dispatch(
|
||||
disassociatePanelFromFrame({
|
||||
panelId,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[allPanels, frame.id, isPanelInFrame, dispatch]
|
||||
);
|
||||
|
||||
const handleDragStart: RndDragCallback = useCallback(
|
||||
(_e, data) => {
|
||||
setDragStartPos({ x: data.x, y: data.y });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDrag: RndDragCallback = useCallback(
|
||||
(_e, data) => {
|
||||
if (!dragStartPos) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state to show frame and child panels moving in real-time
|
||||
const deltaX = data.x - dragStartPos.x;
|
||||
const deltaY = data.y - dragStartPos.y;
|
||||
|
||||
dispatch(setActiveFrameDrag({
|
||||
draggedFrameId: frame.id,
|
||||
deltaX,
|
||||
deltaY,
|
||||
}));
|
||||
},
|
||||
[dispatch, frame.id, dragStartPos]
|
||||
);
|
||||
|
||||
const handleDragStop: RndDragCallback = useCallback(
|
||||
(_e, data) => {
|
||||
// Clear the active frame drag state
|
||||
dispatch(clearActiveFrameDrag());
|
||||
|
||||
// Apply collision detection to constrain the position
|
||||
const constrainedPos = constrainPosition(data.x, data.y, frame.position.width, frame.position.height);
|
||||
|
||||
// Update the frame position (this will move existing child panels)
|
||||
dispatch(
|
||||
updateFramePosition({
|
||||
frameId: frame.id,
|
||||
x: constrainedPos.x,
|
||||
y: constrainedPos.y,
|
||||
})
|
||||
);
|
||||
|
||||
// Check for capturing NEW panels (don't disassociate existing ones)
|
||||
// Skip existing panels to avoid recalculating their offsets after they've moved with the frame
|
||||
setTimeout(() => {
|
||||
updatePanelAssociations(constrainedPos.x, constrainedPos.y, frame.position.width, frame.position.height, true);
|
||||
}, 0);
|
||||
|
||||
setDragStartPos(null);
|
||||
},
|
||||
[dispatch, frame.id, frame.position.width, frame.position.height, constrainPosition, updatePanelAssociations]
|
||||
);
|
||||
|
||||
const handleResize: RndResizeCallback = useCallback(
|
||||
(_e, _direction, ref) => {
|
||||
// Update current size during resize for visual feedback
|
||||
setCurrentSize({
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResizeStop: RndResizeCallback = useCallback(
|
||||
(_e, _direction, ref, _delta, position) => {
|
||||
const newWidth = ref.offsetWidth;
|
||||
const newHeight = ref.offsetHeight;
|
||||
|
||||
// Check if the new size would cause overlap
|
||||
if (wouldOverlapOtherFrames(position.x, position.y, newWidth, newHeight)) {
|
||||
// Revert to the original size if overlap detected
|
||||
setCurrentSize({
|
||||
width: frame.position.width,
|
||||
height: frame.position.height,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local size state
|
||||
setCurrentSize({
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
});
|
||||
|
||||
dispatch(
|
||||
updateFramePosition({
|
||||
frameId: frame.id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(
|
||||
updateFrameSize({
|
||||
frameId: frame.id,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
})
|
||||
);
|
||||
|
||||
// Check for panel associations AFTER updating frame
|
||||
// This ensures we check against the new frame size
|
||||
setTimeout(() => {
|
||||
updatePanelAssociations(position.x, position.y, newWidth, newHeight);
|
||||
}, 0);
|
||||
},
|
||||
[dispatch, frame.id, frame.position.width, frame.position.height, wouldOverlapOtherFrames, updatePanelAssociations]
|
||||
);
|
||||
|
||||
const handleTitleDoubleClick = useCallback(() => {
|
||||
setIsEditingTitle(true);
|
||||
setTitleValue(frame.title);
|
||||
}, [frame.title]);
|
||||
|
||||
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitleValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleTitleBlur = useCallback(() => {
|
||||
if (titleValue.trim() && titleValue !== frame.title) {
|
||||
dispatch(
|
||||
updateFrameTitle({
|
||||
frameId: frame.id,
|
||||
title: titleValue.trim(),
|
||||
})
|
||||
);
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
}, [dispatch, frame.id, titleValue, frame.title]);
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleTitleBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingTitle(false);
|
||||
setTitleValue(frame.title);
|
||||
}
|
||||
},
|
||||
[handleTitleBlur, frame.title]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// If the frame has panels, show the confirmation dialog
|
||||
// Otherwise, delete the frame immediately
|
||||
if (panelsInFrame.length > 0) {
|
||||
setShowDeleteDialog(true);
|
||||
} else {
|
||||
dispatch(removeFrame({ frameId: frame.id }));
|
||||
}
|
||||
},
|
||||
[panelsInFrame.length, dispatch, frame.id]
|
||||
);
|
||||
|
||||
const handleCloseDeleteDialog = useCallback(() => {
|
||||
setShowDeleteDialog(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
position={{ x: frame.position.x, y: frame.position.y }}
|
||||
size={{ width: frame.position.width, height: frame.position.height }}
|
||||
scale={viewport.zoom}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragStop={handleDragStop}
|
||||
onResize={handleResize}
|
||||
onResizeStop={handleResizeStop}
|
||||
bounds="parent"
|
||||
dragHandleClassName="frame-drag-handle"
|
||||
className={styles.frameContainer}
|
||||
style={{ zIndex: frame.position.zIndex }}
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
>
|
||||
<div className={styles.frame}>
|
||||
{/* Border overlay that scales inversely with zoom to maintain constant visual width */}
|
||||
<div
|
||||
className={styles.frameBorder}
|
||||
style={{
|
||||
width: `${currentSize.width * viewport.zoom}px`,
|
||||
height: `${currentSize.height * viewport.zoom}px`,
|
||||
transform: `scale(${1 / viewport.zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={styles.frameHeader + ' frame-drag-handle'}
|
||||
style={{
|
||||
top: `${-30 / viewport.zoom}px`,
|
||||
transform: `scale(${1 / viewport.zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
className={styles.frameTitleInput}
|
||||
value={titleValue}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.frameTitle} onDoubleClick={handleTitleDoubleClick}>
|
||||
{frame.title}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleDeleteClick}
|
||||
tooltip="Delete frame"
|
||||
className={styles.deleteButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showDeleteDialog && (
|
||||
<ConfirmDeleteFrameDialog
|
||||
frameId={frame.id}
|
||||
frameTitle={frame.title}
|
||||
panelCount={panelsInFrame.length}
|
||||
onClose={handleCloseDeleteDialog}
|
||||
/>
|
||||
)}
|
||||
</Rnd>
|
||||
);
|
||||
}
|
||||
|
||||
export const ExploreMapFrame = React.memo(ExploreMapFrameComponent);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
frameContainer: css({
|
||||
cursor: 'default',
|
||||
// Container needs pointer events for resize handles to work
|
||||
// But we'll make the interior non-interactive
|
||||
'& .react-resizable-handle': {
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 10, // Ensure handles are above everything
|
||||
},
|
||||
}),
|
||||
frame: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
backgroundColor: 'transparent',
|
||||
pointerEvents: 'none', // Frame interior doesn't intercept clicks - lets them pass through to panels
|
||||
}),
|
||||
frameBorder: css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
border: `2px solid ${theme.colors.border.strong}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
frameHeader: css({
|
||||
position: 'absolute',
|
||||
top: '-30px',
|
||||
left: '0',
|
||||
padding: theme.spacing(0.5, 1),
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
border: `1px solid ${theme.colors.border.strong}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
cursor: 'move',
|
||||
pointerEvents: 'auto', // Header is interactive
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
deleteButton: css({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
frameTitle: css({
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
minWidth: '100px',
|
||||
}),
|
||||
frameTitleInput: css({
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
outline: `2px solid ${theme.colors.primary.border}`,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
minWidth: '100px',
|
||||
}),
|
||||
});
|
||||
@@ -9,8 +9,10 @@ import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { splitClose } from '../../explore/state/main';
|
||||
import {
|
||||
associatePanelWithFrame,
|
||||
bringPanelToFront,
|
||||
clearActiveDrag,
|
||||
disassociatePanelFromFrame,
|
||||
duplicatePanel,
|
||||
removePanel,
|
||||
selectPanel,
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
updatePanelPosition,
|
||||
updatePanelSize,
|
||||
} from '../state/crdtSlice';
|
||||
import { selectSelectedPanelIds, selectViewport, selectCursors } from '../state/selectors';
|
||||
import { selectSelectedPanelIds, selectViewport, selectCursors, selectFrames } from '../state/selectors';
|
||||
import { ExploreMapPanel } from '../state/types';
|
||||
|
||||
import { ExploreMapPanelContent } from './ExploreMapPanelContent';
|
||||
@@ -37,6 +39,7 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
|
||||
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
|
||||
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
|
||||
const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT));
|
||||
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
|
||||
const isSelected = selectedPanelIds.includes(panel.id);
|
||||
|
||||
// Find all users who have this panel selected (excluding current user)
|
||||
@@ -47,6 +50,47 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
|
||||
// Get the active drag info from a parent context (if any panel is being dragged)
|
||||
const activeDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeDrag);
|
||||
|
||||
// Get active frame drag info
|
||||
const activeFrameDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeFrameDrag);
|
||||
|
||||
// Check if panel intersects with any frame (>50% overlap)
|
||||
const checkFrameIntersection = useCallback((
|
||||
panelX: number,
|
||||
panelY: number,
|
||||
panelWidth: number,
|
||||
panelHeight: number
|
||||
): string | null => {
|
||||
for (const frame of Object.values(frames)) {
|
||||
const panelLeft = panelX;
|
||||
const panelRight = panelX + panelWidth;
|
||||
const panelTop = panelY;
|
||||
const panelBottom = panelY + panelHeight;
|
||||
|
||||
const frameLeft = frame.position.x;
|
||||
const frameRight = frame.position.x + frame.position.width;
|
||||
const frameTop = frame.position.y;
|
||||
const frameBottom = frame.position.y + frame.position.height;
|
||||
|
||||
// Calculate intersection area
|
||||
const intersectLeft = Math.max(panelLeft, frameLeft);
|
||||
const intersectRight = Math.min(panelRight, frameRight);
|
||||
const intersectTop = Math.max(panelTop, frameTop);
|
||||
const intersectBottom = Math.min(panelBottom, frameBottom);
|
||||
|
||||
if (intersectRight > intersectLeft && intersectBottom > intersectTop) {
|
||||
const intersectArea = (intersectRight - intersectLeft) * (intersectBottom - intersectTop);
|
||||
const panelArea = panelWidth * panelHeight;
|
||||
|
||||
// If >50% of panel is inside frame, consider it contained
|
||||
if (intersectArea / panelArea > 0.5) {
|
||||
return frame.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [frames]);
|
||||
|
||||
// Calculate effective position considering active drag
|
||||
let effectiveX = panel.position.x;
|
||||
let effectiveY = panel.position.y;
|
||||
@@ -57,6 +101,12 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
|
||||
effectiveY += activeDragInfo.deltaY;
|
||||
}
|
||||
|
||||
// If this panel's frame is being dragged, apply the frame drag offset
|
||||
if (activeFrameDragInfo && panel.frameId === activeFrameDragInfo.draggedFrameId) {
|
||||
effectiveX += activeFrameDragInfo.deltaX;
|
||||
effectiveY += activeFrameDragInfo.deltaY;
|
||||
}
|
||||
|
||||
const handleDragStart: RndDragCallback = useCallback(
|
||||
(_e, data) => {
|
||||
setDragStartPos({ x: data.x, y: data.y });
|
||||
@@ -116,11 +166,76 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
|
||||
y: data.y,
|
||||
})
|
||||
);
|
||||
|
||||
// Check if panel should be associated with a frame
|
||||
const intersectingFrameId = checkFrameIntersection(
|
||||
data.x,
|
||||
data.y,
|
||||
panel.position.width,
|
||||
panel.position.height
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Association] Panel drag ended:', {
|
||||
panelId: panel.id,
|
||||
currentFrameId: panel.frameId,
|
||||
intersectingFrameId,
|
||||
panelPos: { x: data.x, y: data.y },
|
||||
willAssociate: intersectingFrameId && intersectingFrameId !== panel.frameId,
|
||||
willDisassociate: !intersectingFrameId && panel.frameId,
|
||||
});
|
||||
|
||||
if (intersectingFrameId && intersectingFrameId !== panel.frameId) {
|
||||
// Panel moved into a frame
|
||||
const frame = frames[intersectingFrameId];
|
||||
const offsetX = data.x - frame.position.x;
|
||||
const offsetY = data.y - frame.position.y;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Association] Associating panel with frame:', {
|
||||
panelId: panel.id,
|
||||
frameId: intersectingFrameId,
|
||||
offset: { offsetX, offsetY },
|
||||
});
|
||||
|
||||
dispatch(
|
||||
associatePanelWithFrame({
|
||||
panelId: panel.id,
|
||||
frameId: intersectingFrameId,
|
||||
offsetX,
|
||||
offsetY,
|
||||
})
|
||||
);
|
||||
} else if (!intersectingFrameId && panel.frameId) {
|
||||
// Panel moved out of frame
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Association] Disassociating panel from frame:', {
|
||||
panelId: panel.id,
|
||||
frameId: panel.frameId,
|
||||
});
|
||||
|
||||
dispatch(
|
||||
disassociatePanelFromFrame({
|
||||
panelId: panel.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
setDragStartPos(null);
|
||||
},
|
||||
[dispatch, panel.id, dragStartPos, isSelected, selectedPanelIds.length]
|
||||
[
|
||||
dispatch,
|
||||
panel.id,
|
||||
panel.position.width,
|
||||
panel.position.height,
|
||||
panel.frameId,
|
||||
dragStartPos,
|
||||
isSelected,
|
||||
selectedPanelIds.length,
|
||||
checkFrameIntersection,
|
||||
frames,
|
||||
]
|
||||
);
|
||||
|
||||
const handleResizeStop: RndResizeCallback = useCallback(
|
||||
|
||||
@@ -57,7 +57,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
// Using linear for more predictive movement
|
||||
// Also transition transform for smooth scaling when zoom changes
|
||||
// Add opacity transition for smooth fade in/out when entering/leaving view
|
||||
transition: 'left 0.1s linear, top 0.1s linear, transform 0.2s ease-out, opacity 0.2s ease-out',
|
||||
transition: 'left 0.1s linear, top 0.1s linear, opacity 0.2s ease-out',
|
||||
// Will-change hint for better performance
|
||||
willChange: 'left, top, transform, opacity',
|
||||
// Fade in animation
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ExploreMapPanel, SerializedExploreState } from '../state/types';
|
||||
import { ExploreMapPanel, ExploreMapFrame, SerializedExploreState } from '../state/types';
|
||||
|
||||
import { HybridLogicalClock } from './hlc';
|
||||
import { LWWRegister, createLWWRegister } from './lwwregister';
|
||||
@@ -16,6 +16,7 @@ import { PNCounter } from './pncounter';
|
||||
import {
|
||||
CRDTExploreMapState,
|
||||
CRDTPanelData,
|
||||
CRDTFrameData,
|
||||
CRDTOperation,
|
||||
AddPanelOperation,
|
||||
RemovePanelOperation,
|
||||
@@ -27,6 +28,13 @@ import {
|
||||
UpdateTitleOperation,
|
||||
AddCommentOperation,
|
||||
RemoveCommentOperation,
|
||||
AddFrameOperation,
|
||||
RemoveFrameOperation,
|
||||
UpdateFramePositionOperation,
|
||||
UpdateFrameSizeOperation,
|
||||
UpdateFrameTitleOperation,
|
||||
AssociatePanelWithFrameOperation,
|
||||
DisassociatePanelFromFrameOperation,
|
||||
OperationResult,
|
||||
CRDTExploreMapStateJSON,
|
||||
CommentData,
|
||||
@@ -56,6 +64,8 @@ export class CRDTStateManager {
|
||||
commentData: new Map(),
|
||||
panels: new ORSet<string>(),
|
||||
panelData: new Map(),
|
||||
frames: new ORSet<string>(),
|
||||
frameData: new Map(),
|
||||
zIndexCounter: new PNCounter(),
|
||||
local: {
|
||||
viewport: {
|
||||
@@ -123,6 +133,9 @@ export class CRDTStateManager {
|
||||
mode: data.mode.get(),
|
||||
iframeUrl: data.iframeUrl.get(),
|
||||
createdBy: data.createdBy.get(),
|
||||
frameId: data.frameId.get(),
|
||||
frameOffsetX: data.frameOffsetX.get(),
|
||||
frameOffsetY: data.frameOffsetY.get(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
}
|
||||
@@ -141,6 +154,75 @@ export class CRDTStateManager {
|
||||
return panels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all frame IDs currently in the set
|
||||
*/
|
||||
getFrameIds(): string[] {
|
||||
return this.state.frames.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frame data by ID
|
||||
*/
|
||||
getFrameData(frameId: string): CRDTFrameData | undefined {
|
||||
if (!this.state.frames.contains(frameId)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.state.frameData.get(frameId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a plain object representation of a frame for UI rendering
|
||||
*/
|
||||
getFrameForUI(frameId: string) {
|
||||
const data = this.getFrameData(frameId);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
title: data.title.get(),
|
||||
position: {
|
||||
x: data.positionX.get(),
|
||||
y: data.positionY.get(),
|
||||
width: data.width.get(),
|
||||
height: data.height.get(),
|
||||
zIndex: data.zIndex.get(),
|
||||
},
|
||||
createdBy: data.createdBy.get(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all frames for UI rendering
|
||||
*/
|
||||
getAllFramesForUI(): Record<string, ExploreMapFrame> {
|
||||
const frames: Record<string, ExploreMapFrame> = {};
|
||||
for (const frameId of this.getFrameIds()) {
|
||||
const frame = this.getFrameForUI(frameId);
|
||||
if (frame) {
|
||||
frames[frameId] = frame;
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get all panels in a frame
|
||||
*/
|
||||
getPanelsInFrame(frameId: string): string[] {
|
||||
const panelIds: string[] = [];
|
||||
for (const panelId of this.getPanelIds()) {
|
||||
const panel = this.getPanelData(panelId);
|
||||
if (panel && panel.frameId.get() === frameId) {
|
||||
panelIds.push(panelId);
|
||||
}
|
||||
}
|
||||
return panelIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an add panel operation
|
||||
*/
|
||||
@@ -376,6 +458,162 @@ export class CRDTStateManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an add frame operation
|
||||
*/
|
||||
createAddFrameOperation(
|
||||
frameId: string,
|
||||
title: string,
|
||||
position: { x: number; y: number; width: number; height: number },
|
||||
createdBy?: string
|
||||
): AddFrameOperation {
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'add-frame',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, title, position, createdBy },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remove frame operation
|
||||
*/
|
||||
createRemoveFrameOperation(frameId: string): RemoveFrameOperation | null {
|
||||
if (!this.state.frames.contains(frameId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
const observedTags = this.state.frames.getTags(frameId);
|
||||
|
||||
return {
|
||||
type: 'remove-frame',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, observedTags },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update frame position operation
|
||||
*/
|
||||
createUpdateFramePositionOperation(
|
||||
frameId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): UpdateFramePositionOperation | null {
|
||||
if (!this.state.frames.contains(frameId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'update-frame-position',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, x, y, deltaX, deltaY },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update frame size operation
|
||||
*/
|
||||
createUpdateFrameSizeOperation(
|
||||
frameId: string,
|
||||
width: number,
|
||||
height: number
|
||||
): UpdateFrameSizeOperation | null {
|
||||
if (!this.state.frames.contains(frameId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'update-frame-size',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, width, height },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update frame title operation
|
||||
*/
|
||||
createUpdateFrameTitleOperation(
|
||||
frameId: string,
|
||||
title: string
|
||||
): UpdateFrameTitleOperation | null {
|
||||
if (!this.state.frames.contains(frameId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'update-frame-title',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { frameId, title },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an associate panel with frame operation
|
||||
*/
|
||||
createAssociatePanelWithFrameOperation(
|
||||
panelId: string,
|
||||
frameId: string,
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
): AssociatePanelWithFrameOperation | null {
|
||||
if (!this.state.panels.contains(panelId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'associate-panel-with-frame',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { panelId, frameId, offsetX, offsetY },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a disassociate panel from frame operation
|
||||
*/
|
||||
createDisassociatePanelFromFrameOperation(
|
||||
panelId: string
|
||||
): DisassociatePanelFromFrameOperation | null {
|
||||
if (!this.state.panels.contains(panelId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = this.clock.tick();
|
||||
return {
|
||||
type: 'disassociate-panel-from-frame',
|
||||
mapUid: this.mapUid,
|
||||
operationId: uuidv4(),
|
||||
timestamp,
|
||||
nodeId: this.nodeId,
|
||||
payload: { panelId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all comment IDs
|
||||
*/
|
||||
@@ -438,6 +676,20 @@ export class CRDTStateManager {
|
||||
return this.applyAddComment(operation);
|
||||
case 'remove-comment':
|
||||
return this.applyRemoveComment(operation);
|
||||
case 'add-frame':
|
||||
return this.applyAddFrame(operation);
|
||||
case 'remove-frame':
|
||||
return this.applyRemoveFrame(operation);
|
||||
case 'update-frame-position':
|
||||
return this.applyUpdateFramePosition(operation);
|
||||
case 'update-frame-size':
|
||||
return this.applyUpdateFrameSize(operation);
|
||||
case 'update-frame-title':
|
||||
return this.applyUpdateFrameTitle(operation);
|
||||
case 'associate-panel-with-frame':
|
||||
return this.applyAssociatePanelWithFrame(operation);
|
||||
case 'disassociate-panel-from-frame':
|
||||
return this.applyDisassociatePanelFromFrame(operation);
|
||||
case 'batch':
|
||||
return this.applyBatchOperation(operation);
|
||||
default:
|
||||
@@ -479,6 +731,9 @@ export class CRDTStateManager {
|
||||
mode: new LWWRegister(panelMode, operation.timestamp),
|
||||
iframeUrl: new LWWRegister(undefined, operation.timestamp),
|
||||
createdBy: new LWWRegister(createdBy, operation.timestamp),
|
||||
frameId: new LWWRegister(undefined, operation.timestamp),
|
||||
frameOffsetX: new LWWRegister(undefined, operation.timestamp),
|
||||
frameOffsetY: new LWWRegister(undefined, operation.timestamp),
|
||||
remoteVersion: 0,
|
||||
});
|
||||
}
|
||||
@@ -667,6 +922,130 @@ export class CRDTStateManager {
|
||||
};
|
||||
}
|
||||
|
||||
private applyAddFrame(operation: AddFrameOperation): OperationResult {
|
||||
const { frameId, title, position, createdBy } = operation.payload;
|
||||
|
||||
// Add to OR-Set with operation ID as tag
|
||||
this.state.frames.add(frameId, operation.operationId);
|
||||
|
||||
// Initialize frame data if it doesn't exist
|
||||
if (!this.state.frameData.has(frameId)) {
|
||||
// Frames always use z-index 0 to stay below panels
|
||||
// Panels start from z-index 1 and increment from there
|
||||
const zIndex = 0;
|
||||
|
||||
this.state.frameData.set(frameId, {
|
||||
id: frameId,
|
||||
title: new LWWRegister(title, operation.timestamp),
|
||||
positionX: new LWWRegister(position.x, operation.timestamp),
|
||||
positionY: new LWWRegister(position.y, operation.timestamp),
|
||||
width: new LWWRegister(position.width, operation.timestamp),
|
||||
height: new LWWRegister(position.height, operation.timestamp),
|
||||
zIndex: new LWWRegister(zIndex, operation.timestamp),
|
||||
createdBy: new LWWRegister(createdBy, operation.timestamp),
|
||||
remoteVersion: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, applied: true };
|
||||
}
|
||||
|
||||
private applyRemoveFrame(operation: RemoveFrameOperation): OperationResult {
|
||||
const { frameId, observedTags } = operation.payload;
|
||||
|
||||
// Remove from OR-Set
|
||||
this.state.frames.remove(frameId, observedTags);
|
||||
|
||||
// Disassociate all panels from this frame
|
||||
for (const panelId of this.getPanelIds()) {
|
||||
const panel = this.state.panelData.get(panelId);
|
||||
if (panel && panel.frameId.get() === frameId) {
|
||||
panel.frameId.set(undefined, operation.timestamp);
|
||||
panel.frameOffsetX.set(undefined, operation.timestamp);
|
||||
panel.frameOffsetY.set(undefined, operation.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep frame data as tombstone for CRDT correctness
|
||||
return { success: true, applied: true };
|
||||
}
|
||||
|
||||
private applyUpdateFramePosition(operation: UpdateFramePositionOperation): OperationResult {
|
||||
const { frameId, x, y } = operation.payload;
|
||||
const frameData = this.state.frameData.get(frameId);
|
||||
|
||||
if (!frameData) {
|
||||
return { success: true, applied: false, error: 'Frame not found' };
|
||||
}
|
||||
|
||||
const xUpdated = frameData.positionX.set(x, operation.timestamp);
|
||||
const yUpdated = frameData.positionY.set(y, operation.timestamp);
|
||||
|
||||
return { success: true, applied: xUpdated || yUpdated };
|
||||
}
|
||||
|
||||
private applyUpdateFrameSize(operation: UpdateFrameSizeOperation): OperationResult {
|
||||
const { frameId, width, height } = operation.payload;
|
||||
const frameData = this.state.frameData.get(frameId);
|
||||
|
||||
if (!frameData) {
|
||||
return { success: true, applied: false, error: 'Frame not found' };
|
||||
}
|
||||
|
||||
const widthUpdated = frameData.width.set(width, operation.timestamp);
|
||||
const heightUpdated = frameData.height.set(height, operation.timestamp);
|
||||
|
||||
return { success: true, applied: widthUpdated || heightUpdated };
|
||||
}
|
||||
|
||||
private applyUpdateFrameTitle(operation: UpdateFrameTitleOperation): OperationResult {
|
||||
const { frameId, title } = operation.payload;
|
||||
const frameData = this.state.frameData.get(frameId);
|
||||
|
||||
if (!frameData) {
|
||||
return { success: true, applied: false, error: 'Frame not found' };
|
||||
}
|
||||
|
||||
const updated = frameData.title.set(title, operation.timestamp);
|
||||
|
||||
// Increment remoteVersion only for remote operations
|
||||
if (updated && operation.nodeId !== this.nodeId) {
|
||||
frameData.remoteVersion++;
|
||||
}
|
||||
|
||||
return { success: true, applied: updated };
|
||||
}
|
||||
|
||||
private applyAssociatePanelWithFrame(operation: AssociatePanelWithFrameOperation): OperationResult {
|
||||
const { panelId, frameId, offsetX, offsetY } = operation.payload;
|
||||
const panelData = this.state.panelData.get(panelId);
|
||||
|
||||
if (!panelData) {
|
||||
return { success: true, applied: false, error: 'Panel not found' };
|
||||
}
|
||||
|
||||
const frameUpdated = panelData.frameId.set(frameId, operation.timestamp);
|
||||
const offsetXUpdated = panelData.frameOffsetX.set(offsetX, operation.timestamp);
|
||||
const offsetYUpdated = panelData.frameOffsetY.set(offsetY, operation.timestamp);
|
||||
|
||||
return { success: true, applied: frameUpdated || offsetXUpdated || offsetYUpdated };
|
||||
}
|
||||
|
||||
private applyDisassociatePanelFromFrame(operation: DisassociatePanelFromFrameOperation): OperationResult {
|
||||
const { panelId } = operation.payload;
|
||||
const panelData = this.state.panelData.get(panelId);
|
||||
|
||||
if (!panelData) {
|
||||
return { success: true, applied: false, error: 'Panel not found' };
|
||||
}
|
||||
|
||||
const frameUpdated = panelData.frameId.set(undefined, operation.timestamp);
|
||||
const offsetXUpdated = panelData.frameOffsetX.set(undefined, operation.timestamp);
|
||||
const offsetYUpdated = panelData.frameOffsetY.set(undefined, operation.timestamp);
|
||||
|
||||
return { success: true, applied: frameUpdated || offsetXUpdated || offsetYUpdated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge another CRDT state into this one
|
||||
*/
|
||||
@@ -705,6 +1084,9 @@ export class CRDTStateManager {
|
||||
mode: otherPanelData.mode.clone(),
|
||||
iframeUrl: otherPanelData.iframeUrl.clone(),
|
||||
createdBy: otherPanelData.createdBy.clone(),
|
||||
frameId: otherPanelData.frameId.clone(),
|
||||
frameOffsetX: otherPanelData.frameOffsetX.clone(),
|
||||
frameOffsetY: otherPanelData.frameOffsetY.clone(),
|
||||
remoteVersion: otherPanelData.remoteVersion,
|
||||
});
|
||||
} else {
|
||||
@@ -718,6 +1100,41 @@ export class CRDTStateManager {
|
||||
myPanelData.mode.merge(otherPanelData.mode);
|
||||
myPanelData.iframeUrl.merge(otherPanelData.iframeUrl);
|
||||
myPanelData.createdBy.merge(otherPanelData.createdBy);
|
||||
myPanelData.frameId.merge(otherPanelData.frameId);
|
||||
myPanelData.frameOffsetX.merge(otherPanelData.frameOffsetX);
|
||||
myPanelData.frameOffsetY.merge(otherPanelData.frameOffsetY);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge frame OR-Set
|
||||
this.state.frames.merge(other.frames);
|
||||
|
||||
// Merge frame data
|
||||
for (const [frameId, otherFrameData] of other.frameData.entries()) {
|
||||
const myFrameData = this.state.frameData.get(frameId);
|
||||
|
||||
if (!myFrameData) {
|
||||
// Frame doesn't exist locally - copy it
|
||||
this.state.frameData.set(frameId, {
|
||||
id: otherFrameData.id,
|
||||
title: otherFrameData.title.clone(),
|
||||
positionX: otherFrameData.positionX.clone(),
|
||||
positionY: otherFrameData.positionY.clone(),
|
||||
width: otherFrameData.width.clone(),
|
||||
height: otherFrameData.height.clone(),
|
||||
zIndex: otherFrameData.zIndex.clone(),
|
||||
createdBy: otherFrameData.createdBy.clone(),
|
||||
remoteVersion: otherFrameData.remoteVersion,
|
||||
});
|
||||
} else {
|
||||
// Merge each LWW register
|
||||
myFrameData.title.merge(otherFrameData.title);
|
||||
myFrameData.positionX.merge(otherFrameData.positionX);
|
||||
myFrameData.positionY.merge(otherFrameData.positionY);
|
||||
myFrameData.width.merge(otherFrameData.width);
|
||||
myFrameData.height.merge(otherFrameData.height);
|
||||
myFrameData.zIndex.merge(otherFrameData.zIndex);
|
||||
myFrameData.createdBy.merge(otherFrameData.createdBy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,6 +1161,9 @@ export class CRDTStateManager {
|
||||
mode: data.mode.toJSON(),
|
||||
iframeUrl: data.iframeUrl.toJSON(),
|
||||
createdBy: data.createdBy.toJSON(),
|
||||
frameId: data.frameId.toJSON(),
|
||||
frameOffsetX: data.frameOffsetX.toJSON(),
|
||||
frameOffsetY: data.frameOffsetY.toJSON(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
}
|
||||
@@ -755,6 +1175,23 @@ export class CRDTStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
const frameData: Record<string, any> = {};
|
||||
for (const [frameId, data] of this.state.frameData.entries()) {
|
||||
if (this.state.frames.contains(frameId)) {
|
||||
frameData[frameId] = {
|
||||
id: data.id,
|
||||
title: data.title.toJSON(),
|
||||
positionX: data.positionX.toJSON(),
|
||||
positionY: data.positionY.toJSON(),
|
||||
width: data.width.toJSON(),
|
||||
height: data.height.toJSON(),
|
||||
zIndex: data.zIndex.toJSON(),
|
||||
createdBy: data.createdBy.toJSON(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uid: this.state.uid,
|
||||
title: this.state.title.toJSON(),
|
||||
@@ -762,6 +1199,8 @@ export class CRDTStateManager {
|
||||
commentData,
|
||||
panels: this.state.panels.toJSON(),
|
||||
panelData,
|
||||
frames: this.state.frames.toJSON(),
|
||||
frameData,
|
||||
zIndexCounter: this.state.zIndexCounter.toJSON(),
|
||||
};
|
||||
}
|
||||
@@ -782,6 +1221,7 @@ export class CRDTStateManager {
|
||||
}
|
||||
}
|
||||
manager.state.panels = ORSet.fromJSON(json.panels);
|
||||
manager.state.frames = json.frames ? ORSet.fromJSON(json.frames) : new ORSet<string>();
|
||||
manager.state.zIndexCounter = PNCounter.fromJSON(json.zIndexCounter);
|
||||
|
||||
// Load panel data
|
||||
@@ -800,10 +1240,31 @@ export class CRDTStateManager {
|
||||
mode: data.mode ? LWWRegister.fromJSON(data.mode) : new LWWRegister('explore', defaultTimestamp),
|
||||
iframeUrl: data.iframeUrl ? LWWRegister.fromJSON(data.iframeUrl) : new LWWRegister(undefined, defaultTimestamp),
|
||||
createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp),
|
||||
frameId: data.frameId ? LWWRegister.fromJSON(data.frameId) : new LWWRegister(undefined, defaultTimestamp),
|
||||
frameOffsetX: data.frameOffsetX ? LWWRegister.fromJSON(data.frameOffsetX) : new LWWRegister(undefined, defaultTimestamp),
|
||||
frameOffsetY: data.frameOffsetY ? LWWRegister.fromJSON(data.frameOffsetY) : new LWWRegister(undefined, defaultTimestamp),
|
||||
remoteVersion: data.remoteVersion || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Load frame data
|
||||
if (json.frameData) {
|
||||
for (const [frameId, data] of Object.entries(json.frameData)) {
|
||||
const defaultTimestamp = data.positionX?.timestamp || { nodeId: manager.nodeId, counter: 0, wallClock: Date.now() };
|
||||
manager.state.frameData.set(frameId, {
|
||||
id: data.id,
|
||||
title: LWWRegister.fromJSON(data.title),
|
||||
positionX: LWWRegister.fromJSON(data.positionX),
|
||||
positionY: LWWRegister.fromJSON(data.positionY),
|
||||
width: LWWRegister.fromJSON(data.width),
|
||||
height: LWWRegister.fromJSON(data.height),
|
||||
zIndex: LWWRegister.fromJSON(data.zIndex),
|
||||
createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp),
|
||||
remoteVersion: data.remoteVersion || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,37 @@ export interface CRDTPanelData {
|
||||
// Creator metadata (username of who created the panel)
|
||||
createdBy: LWWRegister<string | undefined>;
|
||||
|
||||
// Frame association properties
|
||||
frameId: LWWRegister<string | undefined>; // Parent frame ID
|
||||
frameOffsetX: LWWRegister<number | undefined>; // Offset from frame origin
|
||||
frameOffsetY: LWWRegister<number | undefined>; // Offset from frame origin
|
||||
|
||||
// Local counter incremented only for remote explore state updates
|
||||
remoteVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRDT state for a single frame
|
||||
*/
|
||||
export interface CRDTFrameData {
|
||||
// Stable identifier
|
||||
id: string;
|
||||
|
||||
// CRDT-replicated properties
|
||||
title: LWWRegister<string>;
|
||||
positionX: LWWRegister<number>;
|
||||
positionY: LWWRegister<number>;
|
||||
width: LWWRegister<number>;
|
||||
height: LWWRegister<number>;
|
||||
zIndex: LWWRegister<number>;
|
||||
|
||||
// Creator metadata (username of who created the frame)
|
||||
createdBy: LWWRegister<string | undefined>;
|
||||
|
||||
// Local counter incremented only for remote title updates
|
||||
remoteVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete CRDT-based Explore Map state
|
||||
*/
|
||||
@@ -69,6 +96,12 @@ export interface CRDTExploreMapState {
|
||||
// Panel data (position, size, content)
|
||||
panelData: Map<string, CRDTPanelData>;
|
||||
|
||||
// Frame collection (OR-Set for add/remove operations)
|
||||
frames: ORSet<string>; // Set of frame IDs
|
||||
|
||||
// Frame data (position, size, title)
|
||||
frameData: Map<string, CRDTFrameData>;
|
||||
|
||||
// Counter for allocating z-indices
|
||||
zIndexCounter: PNCounter;
|
||||
|
||||
@@ -121,6 +154,24 @@ export interface CRDTExploreMapStateJSON {
|
||||
mode: { value: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'; timestamp: HLCTimestamp };
|
||||
iframeUrl: { value: string | undefined; timestamp: HLCTimestamp };
|
||||
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
|
||||
frameId?: { value: string | undefined; timestamp: HLCTimestamp };
|
||||
frameOffsetX?: { value: number | undefined; timestamp: HLCTimestamp };
|
||||
frameOffsetY?: { value: number | undefined; timestamp: HLCTimestamp };
|
||||
remoteVersion?: number;
|
||||
}>;
|
||||
frames?: {
|
||||
adds: Record<string, string[]>;
|
||||
removes: string[];
|
||||
};
|
||||
frameData?: Record<string, {
|
||||
id: string;
|
||||
title: { value: string; timestamp: HLCTimestamp };
|
||||
positionX: { value: number; timestamp: HLCTimestamp };
|
||||
positionY: { value: number; timestamp: HLCTimestamp };
|
||||
width: { value: number; timestamp: HLCTimestamp };
|
||||
height: { value: number; timestamp: HLCTimestamp };
|
||||
zIndex: { value: number; timestamp: HLCTimestamp };
|
||||
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
|
||||
remoteVersion?: number;
|
||||
}>;
|
||||
zIndexCounter: {
|
||||
@@ -143,6 +194,13 @@ export type CRDTOperationType =
|
||||
| 'update-title'
|
||||
| 'add-comment'
|
||||
| 'remove-comment'
|
||||
| 'add-frame'
|
||||
| 'remove-frame'
|
||||
| 'update-frame-position'
|
||||
| 'update-frame-size'
|
||||
| 'update-frame-title'
|
||||
| 'associate-panel-with-frame'
|
||||
| 'disassociate-panel-from-frame'
|
||||
| 'batch'; // For batching multiple operations
|
||||
|
||||
/**
|
||||
@@ -276,6 +334,95 @@ export interface RemoveCommentOperation extends CRDTOperationBase {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add frame operation
|
||||
*/
|
||||
export interface AddFrameOperation extends CRDTOperationBase {
|
||||
type: 'add-frame';
|
||||
payload: {
|
||||
frameId: string;
|
||||
title: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
createdBy?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove frame operation
|
||||
*/
|
||||
export interface RemoveFrameOperation extends CRDTOperationBase {
|
||||
type: 'remove-frame';
|
||||
payload: {
|
||||
frameId: string;
|
||||
observedTags: string[]; // Tags from OR-Set
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frame position operation
|
||||
*/
|
||||
export interface UpdateFramePositionOperation extends CRDTOperationBase {
|
||||
type: 'update-frame-position';
|
||||
payload: {
|
||||
frameId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
deltaX: number; // For batch-updating child panels
|
||||
deltaY: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frame size operation
|
||||
*/
|
||||
export interface UpdateFrameSizeOperation extends CRDTOperationBase {
|
||||
type: 'update-frame-size';
|
||||
payload: {
|
||||
frameId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frame title operation
|
||||
*/
|
||||
export interface UpdateFrameTitleOperation extends CRDTOperationBase {
|
||||
type: 'update-frame-title';
|
||||
payload: {
|
||||
frameId: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Associate panel with frame operation
|
||||
*/
|
||||
export interface AssociatePanelWithFrameOperation extends CRDTOperationBase {
|
||||
type: 'associate-panel-with-frame';
|
||||
payload: {
|
||||
panelId: string;
|
||||
frameId: string;
|
||||
offsetX: number; // Relative to frame's top-left
|
||||
offsetY: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disassociate panel from frame operation
|
||||
*/
|
||||
export interface DisassociatePanelFromFrameOperation extends CRDTOperationBase {
|
||||
type: 'disassociate-panel-from-frame';
|
||||
payload: {
|
||||
panelId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch operation (multiple operations in one)
|
||||
*/
|
||||
@@ -300,6 +447,13 @@ export type CRDTOperation =
|
||||
| UpdateTitleOperation
|
||||
| AddCommentOperation
|
||||
| RemoveCommentOperation
|
||||
| AddFrameOperation
|
||||
| RemoveFrameOperation
|
||||
| UpdateFramePositionOperation
|
||||
| UpdateFrameSizeOperation
|
||||
| UpdateFrameTitleOperation
|
||||
| AssociatePanelWithFrameOperation
|
||||
| DisassociatePanelFromFrameOperation
|
||||
| BatchOperation;
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useDispatch, useSelector } from 'app/types/store';
|
||||
import { exploreMapApi } from '../api/exploreMapApi';
|
||||
import { initializeFromLegacyState, loadState as loadCRDTState } from '../state/crdtSlice';
|
||||
import { loadCanvas } from '../state/exploreMapSlice';
|
||||
import { selectPanels, selectMapTitle, selectViewport } from '../state/selectors';
|
||||
import { selectPanels, selectFrames, selectMapTitle, selectViewport } from '../state/selectors';
|
||||
import { ExploreMapState, initialExploreMapState, SerializedExploreState } from '../state/types';
|
||||
|
||||
const STORAGE_KEY = 'grafana.exploreMap.state';
|
||||
@@ -80,11 +80,15 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
// Load from API
|
||||
try {
|
||||
setLoading(true);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Loading from API:', { uid });
|
||||
const mapData = await exploreMapApi.getExploreMap(uid);
|
||||
|
||||
// Handle empty or missing data (new maps)
|
||||
let parsed: ExploreMapState;
|
||||
if (!mapData.data || mapData.data.trim() === '') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Empty map data, initializing with defaults');
|
||||
// Initialize with default empty state for new maps
|
||||
parsed = {
|
||||
...initialExploreMapState,
|
||||
@@ -96,6 +100,18 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
// Use title from DB column, not from JSON data
|
||||
parsed.uid = mapData.uid;
|
||||
parsed.title = mapData.title;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Loaded map data:', {
|
||||
uid: parsed.uid,
|
||||
title: parsed.title,
|
||||
panelCount: Object.keys(parsed.panels || {}).length,
|
||||
frameCount: Object.keys(parsed.frames || {}).length,
|
||||
frameIds: Object.keys(parsed.frames || {}),
|
||||
frames: parsed.frames,
|
||||
hasCrdtState: !!parsed.crdtState,
|
||||
crdtFrames: parsed.crdtState?.frames,
|
||||
crdtFrameData: parsed.crdtState?.frameData,
|
||||
});
|
||||
}
|
||||
|
||||
// Load into legacy state (for backward compatibility)
|
||||
@@ -104,9 +120,13 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
// Initialize CRDT state from loaded data
|
||||
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
|
||||
if (parsed.crdtState) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Loading CRDT state');
|
||||
// Load the saved CRDT state which includes proper OR-Set metadata
|
||||
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] No CRDT state found, initializing from legacy state');
|
||||
// Fallback to legacy initialization for backward compatibility
|
||||
dispatch(initializeFromLegacyState({
|
||||
uid: parsed.uid,
|
||||
@@ -172,18 +192,32 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
|
||||
// Get current CRDT state as panels
|
||||
const panels = selectPanels(crdtState);
|
||||
const frames = selectFrames(crdtState);
|
||||
const mapTitle = selectMapTitle(crdtState);
|
||||
const viewport = selectViewport(crdtState);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Current state:', {
|
||||
panelCount: Object.keys(panels || {}).length,
|
||||
frameCount: Object.keys(frames || {}).length,
|
||||
frameIds: Object.keys(frames || {}),
|
||||
frames: frames,
|
||||
});
|
||||
|
||||
// Don't persist an empty canvas; this avoids removing a previously saved
|
||||
// non-empty canvas when the in-memory state is still at its initial value.
|
||||
if (Object.keys(panels || {}).length === 0) {
|
||||
// Allow saving if there are either panels or frames
|
||||
if (Object.keys(panels || {}).length === 0 && Object.keys(frames || {}).length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Skipping save - empty canvas');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if CRDT state has actually changed (ignore local UI state like selection)
|
||||
const currentCRDTStateStr = crdtState.crdtStateJSON;
|
||||
if (currentCRDTStateStr === lastSavedCRDTStateRef.current) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Skipping save - no CRDT state changes');
|
||||
// No changes to persist
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +230,6 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
const saveState = async () => {
|
||||
// CRDT panels already contain exploreState from savePanelExploreState actions
|
||||
// We don't need to enrich them with live Explore pane data
|
||||
// console.log('[Persistence] Saving panels:', panels);
|
||||
|
||||
// Save both legacy format (for backward compat) and CRDT state
|
||||
const enrichedState: ExploreMapState = {
|
||||
@@ -204,6 +237,7 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
title: mapTitle,
|
||||
viewport,
|
||||
panels: panels, // Already contains exploreState from CRDT
|
||||
frames: frames, // Frames from CRDT state
|
||||
selectedPanelIds: [],
|
||||
nextZIndex: 1,
|
||||
cursors: {},
|
||||
@@ -211,21 +245,32 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
crdtState: crdtState.crdtStateJSON ? JSON.parse(crdtState.crdtStateJSON) : undefined,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame Persistence] Preparing to save:', {
|
||||
uid,
|
||||
panelCount: Object.keys(panels || {}).length,
|
||||
frameCount: Object.keys(frames || {}).length,
|
||||
frameIds: Object.keys(frames || {}),
|
||||
hasCrdtState: !!enrichedState.crdtState,
|
||||
crdtFramesInState: enrichedState.crdtState?.frames,
|
||||
crdtFrameDataInState: enrichedState.crdtState?.frameData,
|
||||
});
|
||||
|
||||
if (uid) {
|
||||
// Save to API with debounce
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const titleToSave = mapTitle || 'Untitled Map';
|
||||
const dataToSave = enrichedState;
|
||||
await exploreMapApi.updateExploreMap(uid, {
|
||||
title: titleToSave,
|
||||
data: enrichedState,
|
||||
data: dataToSave,
|
||||
});
|
||||
setLastSaved(new Date());
|
||||
// Update last saved state ref to prevent duplicate saves
|
||||
lastSavedCRDTStateRef.current = currentCRDTStateStr;
|
||||
} catch (error) {
|
||||
console.error('Failed to save map to API:', error);
|
||||
dispatch(notifyApp(createErrorNotification('Failed to save explore map', 'Changes may not be persisted')));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -238,7 +283,6 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
// Update last saved state ref to prevent duplicate saves
|
||||
lastSavedCRDTStateRef.current = currentCRDTStateStr;
|
||||
} catch (error) {
|
||||
console.error('Failed to save canvas state to storage:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface ExploreMapCRDTState {
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
};
|
||||
activeFrameDrag?: {
|
||||
draggedFrameId: string;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +83,7 @@ export function createInitialCRDTState(mapUid?: string): ExploreMapCRDTState {
|
||||
isOnline: false,
|
||||
isSyncing: false,
|
||||
activeDrag: undefined,
|
||||
activeFrameDrag: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -120,11 +126,20 @@ const crdtSlice = createSlice({
|
||||
* Load CRDT state from server
|
||||
*/
|
||||
loadState: (state, action: PayloadAction<{ crdtState: CRDTExploreMapStateJSON }>) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame CRDT] Loading state from JSON:', {
|
||||
hasFrames: !!action.payload.crdtState.frames,
|
||||
hasFrameData: !!action.payload.crdtState.frameData,
|
||||
frames: action.payload.crdtState.frames,
|
||||
frameData: action.payload.crdtState.frameData,
|
||||
});
|
||||
const manager = CRDTStateManager.fromJSON(action.payload.crdtState, state.nodeId);
|
||||
saveCRDTManager(state, manager);
|
||||
// Restore uid from the loaded CRDT state
|
||||
const crdtState = manager.getState();
|
||||
state.uid = crdtState.uid;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Frame CRDT] State loaded, frame IDs:', manager.getFrameIds());
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -575,6 +590,226 @@ const crdtSlice = createSlice({
|
||||
state.local.selectedPanelIds = [newPanelId];
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a frame
|
||||
*/
|
||||
addFrame: (state, action: PayloadAction<{
|
||||
position?: { x: number; y: number; width: number; height: number };
|
||||
title?: string;
|
||||
createdBy?: string;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const frameId = uuidv4();
|
||||
const title = action.payload.title || `Frame ${frameId.slice(0, 8)}`;
|
||||
const position = action.payload.position || {
|
||||
x: 200, y: 200, width: 800, height: 600
|
||||
};
|
||||
|
||||
const operation = manager.createAddFrameOperation(
|
||||
frameId,
|
||||
title,
|
||||
position,
|
||||
action.payload.createdBy
|
||||
);
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
state.local.selectedPanelIds = []; // Clear panel selection
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a frame
|
||||
*/
|
||||
removeFrame: (state, action: PayloadAction<{ frameId: string; deletePanels?: boolean }>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
// If deletePanels is true, first remove all panels in the frame
|
||||
if (action.payload.deletePanels) {
|
||||
const panelIds = manager.getPanelsInFrame(action.payload.frameId);
|
||||
for (const panelId of panelIds) {
|
||||
const panelOp = manager.createRemovePanelOperation(panelId);
|
||||
if (panelOp) {
|
||||
manager.applyOperation(panelOp);
|
||||
state.pendingOperations.push(panelOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const operation = manager.createRemoveFrameOperation(action.payload.frameId);
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update frame position
|
||||
*/
|
||||
updateFramePosition: (state, action: PayloadAction<{
|
||||
frameId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
// Calculate delta for batch-updating child panels
|
||||
const frame = manager.getFrameData(action.payload.frameId);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = action.payload.x - frame.positionX.get();
|
||||
const deltaY = action.payload.y - frame.positionY.get();
|
||||
|
||||
// Create frame position operation
|
||||
const frameOp = manager.createUpdateFramePositionOperation(
|
||||
action.payload.frameId,
|
||||
action.payload.x,
|
||||
action.payload.y,
|
||||
deltaX,
|
||||
deltaY
|
||||
);
|
||||
|
||||
if (!frameOp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create operations for all child panels
|
||||
const panelOps: any[] = [];
|
||||
const childPanelIds = manager.getPanelsInFrame(action.payload.frameId);
|
||||
|
||||
for (const panelId of childPanelIds) {
|
||||
const panel = manager.getPanelData(panelId);
|
||||
if (panel) {
|
||||
const newX = panel.positionX.get() + deltaX;
|
||||
const newY = panel.positionY.get() + deltaY;
|
||||
|
||||
const panelOp = manager.createUpdatePanelPositionOperation(panelId, newX, newY);
|
||||
if (panelOp) {
|
||||
panelOps.push(panelOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all operations
|
||||
manager.applyOperation(frameOp);
|
||||
for (const op of panelOps) {
|
||||
manager.applyOperation(op);
|
||||
}
|
||||
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
// Push all operations for broadcast
|
||||
state.pendingOperations.push(frameOp, ...panelOps);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update frame size
|
||||
*/
|
||||
updateFrameSize: (state, action: PayloadAction<{
|
||||
frameId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const operation = manager.createUpdateFrameSizeOperation(
|
||||
action.payload.frameId,
|
||||
action.payload.width,
|
||||
action.payload.height
|
||||
);
|
||||
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update frame title
|
||||
*/
|
||||
updateFrameTitle: (state, action: PayloadAction<{
|
||||
frameId: string;
|
||||
title: string;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const operation = manager.createUpdateFrameTitleOperation(
|
||||
action.payload.frameId,
|
||||
action.payload.title
|
||||
);
|
||||
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Associate panel with frame
|
||||
*/
|
||||
associatePanelWithFrame: (state, action: PayloadAction<{
|
||||
panelId: string;
|
||||
frameId: string;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const operation = manager.createAssociatePanelWithFrameOperation(
|
||||
action.payload.panelId,
|
||||
action.payload.frameId,
|
||||
action.payload.offsetX,
|
||||
action.payload.offsetY
|
||||
);
|
||||
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Disassociate panel from frame
|
||||
*/
|
||||
disassociatePanelFromFrame: (state, action: PayloadAction<{
|
||||
panelId: string;
|
||||
}>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
const operation = manager.createDisassociatePanelFromFrameOperation(
|
||||
action.payload.panelId
|
||||
);
|
||||
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.applyOperation(operation);
|
||||
saveCRDTManager(state, manager);
|
||||
|
||||
state.pendingOperations.push(operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear pending operations (after broadcast)
|
||||
*/
|
||||
@@ -671,6 +906,20 @@ const crdtSlice = createSlice({
|
||||
state.local.activeDrag = undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set active frame drag info (for frame drag visual feedback)
|
||||
*/
|
||||
setActiveFrameDrag: (state, action: PayloadAction<{ draggedFrameId: string; deltaX: number; deltaY: number }>) => {
|
||||
state.local.activeFrameDrag = action.payload;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear active frame drag info
|
||||
*/
|
||||
clearActiveFrameDrag: (state) => {
|
||||
state.local.activeFrameDrag = undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (reset)
|
||||
*/
|
||||
@@ -699,6 +948,13 @@ export const {
|
||||
addComment,
|
||||
removeComment,
|
||||
duplicatePanel,
|
||||
addFrame,
|
||||
removeFrame,
|
||||
updateFramePosition,
|
||||
updateFrameSize,
|
||||
updateFrameTitle,
|
||||
associatePanelWithFrame,
|
||||
disassociatePanelFromFrame,
|
||||
clearPendingOperations,
|
||||
updateViewport,
|
||||
selectPanel,
|
||||
@@ -709,6 +965,8 @@ export const {
|
||||
setSyncingStatus,
|
||||
setActiveDrag,
|
||||
clearActiveDrag,
|
||||
setActiveFrameDrag,
|
||||
clearActiveFrameDrag,
|
||||
clearMap,
|
||||
} = crdtSlice.actions;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { CRDTStateManager } from '../crdt/state';
|
||||
|
||||
import { ExploreMapCRDTState } from './crdtSlice';
|
||||
import { ExploreMapPanel } from './types';
|
||||
import { ExploreMapFrame, ExploreMapPanel } from './types';
|
||||
|
||||
/**
|
||||
* Get CRDT manager from state
|
||||
@@ -30,6 +30,12 @@ function getCRDTManager(state: ExploreMapCRDTState): CRDTStateManager {
|
||||
*/
|
||||
const panelCache = new Map<string, { panel: ExploreMapPanel; data: string }>();
|
||||
|
||||
/**
|
||||
* Cache for individual frame objects
|
||||
* Key: frameId, Value: { frame object, serialized frame data for comparison }
|
||||
*/
|
||||
const frameCache = new Map<string, { frame: ExploreMapFrame; data: string }>();
|
||||
|
||||
/**
|
||||
* Select all panels as a Record (for compatibility with existing UI)
|
||||
*
|
||||
@@ -121,6 +127,111 @@ export const selectPanelIds = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Select all frames as a Record (for compatibility with existing UI)
|
||||
*
|
||||
* OPTIMIZATION: This selector caches individual frame objects and only creates
|
||||
* new frame objects when the underlying CRDT data for that specific frame changes.
|
||||
* This prevents unnecessary re-renders of unchanged frames when another frame is modified.
|
||||
*/
|
||||
export const selectFrames = createSelector(
|
||||
[
|
||||
(state: ExploreMapCRDTState) => state.crdtStateJSON,
|
||||
(state: ExploreMapCRDTState) => state.nodeId,
|
||||
(state: ExploreMapCRDTState) => state.uid,
|
||||
],
|
||||
(crdtStateJSON, nodeId, uid): Record<string, ExploreMapFrame> => {
|
||||
const state: ExploreMapCRDTState = {
|
||||
uid,
|
||||
crdtStateJSON,
|
||||
nodeId,
|
||||
sessionId: '', // Not needed for frame selection
|
||||
pendingOperations: [],
|
||||
local: {
|
||||
viewport: { zoom: 1, panX: 0, panY: 0 },
|
||||
selectedPanelIds: [],
|
||||
cursors: {},
|
||||
isOnline: false,
|
||||
isSyncing: false,
|
||||
},
|
||||
};
|
||||
const manager = getCRDTManager(state);
|
||||
const frames: Record<string, ExploreMapFrame> = {};
|
||||
const currentFrameIds = new Set(manager.getFrameIds());
|
||||
|
||||
// Clean up cache for removed frames
|
||||
for (const cachedFrameId of frameCache.keys()) {
|
||||
if (!currentFrameIds.has(cachedFrameId)) {
|
||||
frameCache.delete(cachedFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Build frames object, reusing cached objects when data hasn't changed
|
||||
for (const frameId of currentFrameIds) {
|
||||
const frameData = manager.getFrameForUI(frameId);
|
||||
if (!frameData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Serialize the frame data to detect changes
|
||||
const serializedData = JSON.stringify(frameData);
|
||||
const cached = frameCache.get(frameId);
|
||||
|
||||
// Reuse cached frame if data hasn't changed
|
||||
if (cached && cached.data === serializedData) {
|
||||
frames[frameId] = cached.frame;
|
||||
} else {
|
||||
// Frame is new or changed, create new object and cache it
|
||||
const frame = frameData as ExploreMapFrame;
|
||||
frames[frameId] = frame;
|
||||
frameCache.set(frameId, { frame, data: serializedData });
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Select a single frame by ID
|
||||
*/
|
||||
export const selectFrame = createSelector(
|
||||
[
|
||||
(state: ExploreMapCRDTState) => state,
|
||||
(_state: ExploreMapCRDTState, frameId: string) => frameId,
|
||||
],
|
||||
(state, frameId): ExploreMapFrame | undefined => {
|
||||
const manager = getCRDTManager(state);
|
||||
const frameData = manager.getFrameForUI(frameId);
|
||||
return frameData ? (frameData as ExploreMapFrame) : undefined;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Select frame IDs
|
||||
*/
|
||||
export const selectFrameIds = createSelector(
|
||||
[(state: ExploreMapCRDTState) => state],
|
||||
(state): string[] => {
|
||||
const manager = getCRDTManager(state);
|
||||
return manager.getFrameIds();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Select all panels in a specific frame
|
||||
*/
|
||||
export const selectPanelsInFrame = createSelector(
|
||||
[
|
||||
(state: ExploreMapCRDTState) => state,
|
||||
(_state: ExploreMapCRDTState, frameId: string) => frameId,
|
||||
],
|
||||
(state, frameId): string[] => {
|
||||
const manager = getCRDTManager(state);
|
||||
return manager.getPanelsInFrame(frameId);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Select map title
|
||||
*/
|
||||
@@ -347,6 +458,7 @@ export const selectActiveUsers = createSelector(
|
||||
export const selectLegacyState = createSelector(
|
||||
[
|
||||
selectPanels,
|
||||
selectFrames,
|
||||
selectMapTitle,
|
||||
selectViewport,
|
||||
selectSelectedPanelIds,
|
||||
@@ -354,7 +466,7 @@ export const selectLegacyState = createSelector(
|
||||
selectMapUid,
|
||||
(state: ExploreMapCRDTState) => state,
|
||||
],
|
||||
(panels, title, viewport, selectedPanelIds, cursors, uid, state) => {
|
||||
(panels, frames, title, viewport, selectedPanelIds, cursors, uid, state) => {
|
||||
const manager = getCRDTManager(state);
|
||||
const crdtState = manager.getState();
|
||||
|
||||
@@ -363,6 +475,7 @@ export const selectLegacyState = createSelector(
|
||||
title,
|
||||
viewport,
|
||||
panels,
|
||||
frames,
|
||||
selectedPanelIds,
|
||||
nextZIndex: crdtState.zIndexCounter.value() + 1,
|
||||
cursors,
|
||||
|
||||
@@ -41,6 +41,20 @@ export interface ExploreMapPanel {
|
||||
* Username of the user who created this panel
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* Frame association properties
|
||||
*/
|
||||
frameId?: string; // Parent frame ID
|
||||
frameOffsetX?: number; // Offset from frame origin
|
||||
frameOffsetY?: number; // Offset from frame origin
|
||||
}
|
||||
|
||||
export interface ExploreMapFrame {
|
||||
id: string;
|
||||
title: string;
|
||||
position: PanelPosition; // Reuse position type
|
||||
createdBy?: string;
|
||||
remoteVersion?: number;
|
||||
}
|
||||
|
||||
export interface CanvasViewport {
|
||||
@@ -65,6 +79,7 @@ export interface ExploreMapState {
|
||||
title?: string;
|
||||
viewport: CanvasViewport;
|
||||
panels: Record<string, ExploreMapPanel>;
|
||||
frames: Record<string, ExploreMapFrame>;
|
||||
selectedPanelIds: string[];
|
||||
nextZIndex: number;
|
||||
cursors: Record<string, UserCursor>;
|
||||
@@ -84,6 +99,7 @@ export const initialExploreMapState: ExploreMapState = {
|
||||
panY: -4460, // -(5000 - 1080/2) = -4460
|
||||
},
|
||||
panels: {},
|
||||
frames: {},
|
||||
selectedPanelIds: [],
|
||||
nextZIndex: 1,
|
||||
cursors: {},
|
||||
|
||||
Reference in New Issue
Block a user