frontend: desk layout
This commit is contained in:
@@ -44,6 +44,127 @@ const resolveTagKey = (tag) => {
|
|||||||
return key != null ? String(key) : null;
|
return key != null ? String(key) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateInitialLayout = (
|
||||||
|
entries,
|
||||||
|
{
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
padding,
|
||||||
|
startZ = 0,
|
||||||
|
rotationRange = ROTATION_RANGE,
|
||||||
|
minSpacing = 48,
|
||||||
|
shelfWidth = 0,
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const layout = new Map();
|
||||||
|
let currentZ = startZ;
|
||||||
|
let maxZ = startZ;
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
return { layout, maxZ };
|
||||||
|
}
|
||||||
|
|
||||||
|
const shelfOffset = Math.max(shelfWidth, 0);
|
||||||
|
const usableWidth = Math.max(canvasWidth - shelfOffset - padding * 2, 1);
|
||||||
|
const usableHeight = Math.max(canvasHeight - padding * 2, 1);
|
||||||
|
|
||||||
|
const spacingBuffer = Math.max(minSpacing, 0);
|
||||||
|
const placed = [];
|
||||||
|
|
||||||
|
const resolveBounds = (width, height) => {
|
||||||
|
const halfWidth = width / 2;
|
||||||
|
const halfHeight = height / 2;
|
||||||
|
return {
|
||||||
|
minCenterX: padding + halfWidth,
|
||||||
|
maxCenterX: Math.max(
|
||||||
|
padding + halfWidth,
|
||||||
|
canvasWidth - shelfOffset - padding - halfWidth,
|
||||||
|
),
|
||||||
|
minCenterY: padding + halfHeight,
|
||||||
|
maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateCandidateSpacing = (x, y, radius) => {
|
||||||
|
if (!placed.length) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
let best = Number.POSITIVE_INFINITY;
|
||||||
|
for (let i = 0; i < placed.length; i += 1) {
|
||||||
|
const item = placed[i];
|
||||||
|
const dx = item.x - x;
|
||||||
|
const dy = item.y - y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer;
|
||||||
|
if (distance < best) {
|
||||||
|
best = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const width = Number(entry.width) || 0;
|
||||||
|
const height = Number(entry.height) || 0;
|
||||||
|
if (!entry.id || width <= 0 || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height);
|
||||||
|
|
||||||
|
const radius = Math.sqrt(width * width + height * height) / 2;
|
||||||
|
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
let bestX = (minCenterX + maxCenterX) / 2;
|
||||||
|
let bestY = (minCenterY + maxCenterY) / 2;
|
||||||
|
const samplesPerAxis = 14;
|
||||||
|
for (let gx = 0; gx < samplesPerAxis; gx += 1) {
|
||||||
|
const fracX = (gx + 0.5) / samplesPerAxis;
|
||||||
|
for (let gy = 0; gy < samplesPerAxis; gy += 1) {
|
||||||
|
const fracY = (gy + 0.5) / samplesPerAxis;
|
||||||
|
const candidateX = minCenterX + fracX * (maxCenterX - minCenterX);
|
||||||
|
const candidateY = minCenterY + fracY * (maxCenterY - minCenterY);
|
||||||
|
const edgeSpacing = Math.min(
|
||||||
|
candidateX - minCenterX,
|
||||||
|
maxCenterX - candidateX,
|
||||||
|
candidateY - minCenterY,
|
||||||
|
maxCenterY - candidateY,
|
||||||
|
) - spacingBuffer * 0.5;
|
||||||
|
if (edgeSpacing <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius);
|
||||||
|
const score = Math.min(edgeSpacing, neighborSpacing);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestX = candidateX;
|
||||||
|
bestY = candidateY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const centerX = clamp(bestX, minCenterX, maxCenterX);
|
||||||
|
const centerY = clamp(bestY, minCenterY, maxCenterY);
|
||||||
|
|
||||||
|
const rotation = randomRangeFromSeed(
|
||||||
|
buildKey(entry.id, 'rotation'),
|
||||||
|
-rotationRange,
|
||||||
|
rotationRange,
|
||||||
|
);
|
||||||
|
|
||||||
|
currentZ += 1;
|
||||||
|
layout.set(entry.id, {
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
rotation,
|
||||||
|
z: currentZ,
|
||||||
|
});
|
||||||
|
maxZ = Math.max(maxZ, currentZ);
|
||||||
|
|
||||||
|
placed.push({ x: centerX, y: centerY, radius });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { layout, maxZ };
|
||||||
|
};
|
||||||
|
|
||||||
const createDragPreview = (node, clientX, clientY) => {
|
const createDragPreview = (node, clientX, clientY) => {
|
||||||
if (!(node instanceof HTMLElement)) {
|
if (!(node instanceof HTMLElement)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -323,6 +444,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
|
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
const tagShelfRef = useRef(null);
|
||||||
const layoutRef = useRef(new Map());
|
const layoutRef = useRef(new Map());
|
||||||
const itemRefs = useRef(new Map());
|
const itemRefs = useRef(new Map());
|
||||||
const zCounterRef = useRef(10);
|
const zCounterRef = useRef(10);
|
||||||
@@ -330,6 +452,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
||||||
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||||
|
const [tagShelfWidth, setTagShelfWidth] = useState(0);
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState(null);
|
||||||
const [zoomedId, setZoomedId] = useState(null);
|
const [zoomedId, setZoomedId] = useState(null);
|
||||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||||
@@ -697,6 +820,30 @@ const SkeuomorphicWorkspace = ({
|
|||||||
[resolvePreviewDimensions],
|
[resolvePreviewDimensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const shelfNode = tagShelfRef.current;
|
||||||
|
if (!shelfNode || !availableTags.length) {
|
||||||
|
setTagShelfWidth(0);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const rect = shelfNode.getBoundingClientRect();
|
||||||
|
setTagShelfWidth(Math.ceil(rect.width));
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => window.removeEventListener('resize', measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => measure());
|
||||||
|
observer.observe(shelfNode);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [availableTags.length]);
|
||||||
|
|
||||||
const syncLayoutSnapshot = useCallback(() => {
|
const syncLayoutSnapshot = useCallback(() => {
|
||||||
setLayoutSnapshot(new Map(layoutRef.current));
|
setLayoutSnapshot(new Map(layoutRef.current));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -705,6 +852,10 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return () => {};
|
if (!container) return () => {};
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.log('[skeuo] canvas element', container);
|
||||||
|
}
|
||||||
|
|
||||||
const commitSize = () => {
|
const commitSize = () => {
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const width = Math.floor(rect.width) || 0;
|
const width = Math.floor(rect.width) || 0;
|
||||||
@@ -733,63 +884,94 @@ const SkeuomorphicWorkspace = ({
|
|||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!items.length) {
|
const container = containerRef.current;
|
||||||
layoutRef.current = new Map();
|
if (!container || !canvasSize.width || !canvasSize.height) {
|
||||||
syncLayoutSnapshot();
|
return () => {};
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = layoutRef.current;
|
const rafId = requestAnimationFrame(() => {
|
||||||
const next = new Map();
|
if (!items.length) {
|
||||||
let maxZ = zCounterRef.current;
|
layoutRef.current = new Map();
|
||||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
syncLayoutSnapshot();
|
||||||
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
|
|
||||||
|
|
||||||
items.forEach((doc) => {
|
|
||||||
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
|
|
||||||
const halfWidth = docWidth / 2;
|
|
||||||
const halfHeight = docHeight / 2;
|
|
||||||
|
|
||||||
const minCenterX = CANVAS_PADDING + halfWidth;
|
|
||||||
const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth);
|
|
||||||
const minCenterY = CANVAS_PADDING + halfHeight;
|
|
||||||
const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight);
|
|
||||||
|
|
||||||
const existing = previous.get(doc.id);
|
|
||||||
if (existing) {
|
|
||||||
const defaultCenterX = (minCenterX + maxCenterX) / 2;
|
|
||||||
const defaultCenterY = (minCenterY + maxCenterY) / 2;
|
|
||||||
const prevCenterX =
|
|
||||||
typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX;
|
|
||||||
const prevCenterY =
|
|
||||||
typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY;
|
|
||||||
const centerX = clamp(prevCenterX, minCenterX, maxCenterX);
|
|
||||||
const centerY = clamp(prevCenterY, minCenterY, maxCenterY);
|
|
||||||
const rotation = existing.rotation ?? 0;
|
|
||||||
const z = existing.z ?? maxZ;
|
|
||||||
maxZ = Math.max(maxZ, z);
|
|
||||||
next.set(doc.id, { centerX, centerY, rotation, z });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedX = seededRandom(`${resolveSizeKey(doc)}:centerX`);
|
const previous = layoutRef.current;
|
||||||
const seedY = seededRandom(`${resolveSizeKey(doc)}:centerY`);
|
const next = new Map();
|
||||||
const rangeX = Math.max(maxCenterX - minCenterX, 0);
|
let maxZ = zCounterRef.current;
|
||||||
const rangeY = Math.max(maxCenterY - minCenterY, 0);
|
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||||
const centerX =
|
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
|
||||||
rangeX > 0 ? minCenterX + seedX * rangeX : (minCenterX + maxCenterX) / 2;
|
const docsNeedingLayout = [];
|
||||||
const centerY =
|
|
||||||
rangeY > 0 ? minCenterY + seedY * rangeY : (minCenterY + maxCenterY) / 2;
|
items.forEach((doc) => {
|
||||||
const rotation = randomRangeFromSeed(buildKey(doc.id, 'rotation'), -ROTATION_RANGE, ROTATION_RANGE);
|
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
|
||||||
maxZ += 1;
|
const halfWidth = docWidth / 2;
|
||||||
next.set(doc.id, { centerX, centerY, rotation, z: maxZ });
|
const halfHeight = docHeight / 2;
|
||||||
|
|
||||||
|
const minCenterX = CANVAS_PADDING + halfWidth;
|
||||||
|
const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth);
|
||||||
|
const minCenterY = CANVAS_PADDING + halfHeight;
|
||||||
|
const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight);
|
||||||
|
|
||||||
|
const existing = previous.get(doc.id);
|
||||||
|
if (existing) {
|
||||||
|
const defaultCenterX = (minCenterX + maxCenterX) / 2;
|
||||||
|
const defaultCenterY = (minCenterY + maxCenterY) / 2;
|
||||||
|
const prevCenterX =
|
||||||
|
typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX;
|
||||||
|
const prevCenterY =
|
||||||
|
typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY;
|
||||||
|
const centerX = clamp(prevCenterX, minCenterX, maxCenterX);
|
||||||
|
const centerY = clamp(prevCenterY, minCenterY, maxCenterY);
|
||||||
|
const rotation = existing.rotation ?? 0;
|
||||||
|
const z = existing.z ?? maxZ;
|
||||||
|
maxZ = Math.max(maxZ, z);
|
||||||
|
next.set(doc.id, { centerX, centerY, rotation, z });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
docsNeedingLayout.push({
|
||||||
|
id: doc.id,
|
||||||
|
width: docWidth,
|
||||||
|
height: docHeight,
|
||||||
|
seedKey: resolveSizeKey(doc),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (docsNeedingLayout.length) {
|
||||||
|
const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout(
|
||||||
|
docsNeedingLayout,
|
||||||
|
{
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
padding: CANVAS_PADDING,
|
||||||
|
startZ: maxZ,
|
||||||
|
rotationRange: ROTATION_RANGE,
|
||||||
|
minSpacing: 48,
|
||||||
|
shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
generatedLayout.forEach((entry, docId) => {
|
||||||
|
next.set(docId, entry);
|
||||||
|
});
|
||||||
|
maxZ = Math.max(maxZ, updatedMaxZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutRef.current = next;
|
||||||
|
zCounterRef.current = Math.max(zCounterRef.current, maxZ);
|
||||||
|
syncLayoutSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
layoutRef.current = next;
|
return () => cancelAnimationFrame(rafId);
|
||||||
zCounterRef.current = Math.max(zCounterRef.current, maxZ);
|
}, [
|
||||||
syncLayoutSnapshot();
|
items,
|
||||||
}, [items, canvasSize.width, canvasSize.height, ensureDocumentSize, syncLayoutSnapshot]);
|
canvasSize.width,
|
||||||
|
canvasSize.height,
|
||||||
|
ensureDocumentSize,
|
||||||
|
syncLayoutSnapshot,
|
||||||
|
tagShelfWidth,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draggingId && !items.some((doc) => doc.id === draggingId)) {
|
if (draggingId && !items.some((doc) => doc.id === draggingId)) {
|
||||||
@@ -1492,7 +1674,11 @@ const SkeuomorphicWorkspace = ({
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
{(availableTags.length > 0 || onCreateTag) && (
|
{(availableTags.length > 0 || onCreateTag) && (
|
||||||
<div className="skeuo-tag-shelf" aria-label="Available tags">
|
<div
|
||||||
|
className="skeuo-tag-shelf"
|
||||||
|
aria-label="Available tags"
|
||||||
|
ref={tagShelfRef}
|
||||||
|
>
|
||||||
{availableTags.map((tag) => {
|
{availableTags.map((tag) => {
|
||||||
const colorValue = normalizeColor(tag.color);
|
const colorValue = normalizeColor(tag.color);
|
||||||
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
|
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
|
||||||
|
|||||||
Reference in New Issue
Block a user