frontend: desk layout
This commit is contained in:
@@ -44,6 +44,127 @@ const resolveTagKey = (tag) => {
|
||||
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) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return null;
|
||||
@@ -323,6 +444,7 @@ const SkeuomorphicWorkspace = ({
|
||||
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const tagShelfRef = useRef(null);
|
||||
const layoutRef = useRef(new Map());
|
||||
const itemRefs = useRef(new Map());
|
||||
const zCounterRef = useRef(10);
|
||||
@@ -330,6 +452,7 @@ const SkeuomorphicWorkspace = ({
|
||||
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
||||
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
const [tagShelfWidth, setTagShelfWidth] = useState(0);
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [zoomedId, setZoomedId] = useState(null);
|
||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||
@@ -697,6 +820,30 @@ const SkeuomorphicWorkspace = ({
|
||||
[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(() => {
|
||||
setLayoutSnapshot(new Map(layoutRef.current));
|
||||
}, []);
|
||||
@@ -705,6 +852,10 @@ const SkeuomorphicWorkspace = ({
|
||||
const container = containerRef.current;
|
||||
if (!container) return () => {};
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('[skeuo] canvas element', container);
|
||||
}
|
||||
|
||||
const commitSize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = Math.floor(rect.width) || 0;
|
||||
@@ -733,63 +884,94 @@ const SkeuomorphicWorkspace = ({
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!items.length) {
|
||||
layoutRef.current = new Map();
|
||||
syncLayoutSnapshot();
|
||||
return;
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !canvasSize.width || !canvasSize.height) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const previous = layoutRef.current;
|
||||
const next = new Map();
|
||||
let maxZ = zCounterRef.current;
|
||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||
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 });
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (!items.length) {
|
||||
layoutRef.current = new Map();
|
||||
syncLayoutSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
const seedX = seededRandom(`${resolveSizeKey(doc)}:centerX`);
|
||||
const seedY = seededRandom(`${resolveSizeKey(doc)}:centerY`);
|
||||
const rangeX = Math.max(maxCenterX - minCenterX, 0);
|
||||
const rangeY = Math.max(maxCenterY - minCenterY, 0);
|
||||
const centerX =
|
||||
rangeX > 0 ? minCenterX + seedX * rangeX : (minCenterX + maxCenterX) / 2;
|
||||
const centerY =
|
||||
rangeY > 0 ? minCenterY + seedY * rangeY : (minCenterY + maxCenterY) / 2;
|
||||
const rotation = randomRangeFromSeed(buildKey(doc.id, 'rotation'), -ROTATION_RANGE, ROTATION_RANGE);
|
||||
maxZ += 1;
|
||||
next.set(doc.id, { centerX, centerY, rotation, z: maxZ });
|
||||
const previous = layoutRef.current;
|
||||
const next = new Map();
|
||||
let maxZ = zCounterRef.current;
|
||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
|
||||
const docsNeedingLayout = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
zCounterRef.current = Math.max(zCounterRef.current, maxZ);
|
||||
syncLayoutSnapshot();
|
||||
}, [items, canvasSize.width, canvasSize.height, ensureDocumentSize, syncLayoutSnapshot]);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [
|
||||
items,
|
||||
canvasSize.width,
|
||||
canvasSize.height,
|
||||
ensureDocumentSize,
|
||||
syncLayoutSnapshot,
|
||||
tagShelfWidth,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draggingId && !items.some((doc) => doc.id === draggingId)) {
|
||||
@@ -1492,7 +1674,11 @@ const SkeuomorphicWorkspace = ({
|
||||
})
|
||||
)}
|
||||
{(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) => {
|
||||
const colorValue = normalizeColor(tag.color);
|
||||
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
|
||||
|
||||
Reference in New Issue
Block a user