frontend: desk layout
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 45s

This commit is contained in:
2025-10-15 19:52:15 +02:00
parent 6b89913efd
commit b5119a8e6f
+237 -51
View File
@@ -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');