@@ -133,6 +179,6 @@
background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
- background-size: 40px 40px;
+ background-size: 20px 20px;
}
diff --git a/apps/web/src/lib/components/admin/FloorplanEditor.svelte b/apps/web/src/lib/components/admin/FloorplanEditor.svelte
index 6066286..3954f5c 100644
--- a/apps/web/src/lib/components/admin/FloorplanEditor.svelte
+++ b/apps/web/src/lib/components/admin/FloorplanEditor.svelte
@@ -10,6 +10,43 @@
let container: HTMLDivElement;
let spaces = [...initialSpaces];
+ let containerWidth = 800;
+ let containerHeight = 600;
+
+ // Keep local spaces in sync if initialSpaces changes from parent (e.g. after full page load)
+ $: if (initialSpaces) {
+ // Only re-sync if the number of spaces changed or they are all new
+ // to avoid wiping out unsaved local changes while editing.
+ // In most cases, initialSpaces only changes on mount or full refresh.
+ if (spaces.length === 0 || (initialSpaces.length > 0 && !initialSpaces[0].id.startsWith('temp-') && spaces[0]?.id?.startsWith('temp-'))) {
+ spaces = [...initialSpaces];
+ updateContainerSizeToFitAll();
+ }
+ }
+
+ function updateContainerSizeToFitAll() {
+ if (spaces.length === 0) return;
+
+ let maxR = 800;
+ let maxB = 600;
+
+ spaces.forEach(s => {
+ const w = s.width || (s.type === 'DESK' ? 12 : 20);
+ const h = s.height || (s.type === 'DESK' ? 8 : 15);
+
+ // Calculate right and bottom in pixels relative to 800x600 (or current if it's the master)
+ // Actually, since we're starting from percentages, we should probably pick a base pixel size.
+ // Let's use 800 as the base width.
+ const r = ((s.x + w) / 100) * containerWidth;
+ const b = ((s.y + h) / 100) * containerHeight;
+
+ if (r > maxR) maxR = r;
+ if (b > maxB) maxB = b;
+ });
+
+ containerWidth = Math.max(800, Math.ceil(maxR + 40)); // +40 for some padding
+ containerHeight = Math.max(600, Math.ceil(maxB + 40));
+ }
let toolboxItems = [
{ type: 'DESK', icon: Laptop, label: 'Desk' },
{ type: 'MEETING_ROOM', icon: Users, label: 'Meeting Room' },
@@ -21,17 +58,25 @@
let editingId: string | null = null;
let fileInput: HTMLInputElement;
- onMount(() => {
- initInteract();
- });
-
- function initInteract() {
- interact('.draggable-space').draggable({
+ function interactAction(node: HTMLElement, space: any) {
+ const isDesk = space.type === 'DESK';
+ const interactable = interact(node);
+
+ interactable.draggable({
inertia: true,
+ allowFrom: '.drag-handle',
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
+ }),
+ interact.modifiers.snap({
+ targets: [
+ interact.snappers.grid({ x: 20, y: 20 })
+ ],
+ range: Infinity,
+ relativePoints: [ { x: 0, y: 0 } ],
+ offset: 'parent'
})
],
autoScroll: true,
@@ -40,6 +85,37 @@
end: onDragEnd
}
});
+
+ if (!isDesk) {
+ interactable.resizable({
+ edges: { right: '.resize-handle', bottom: '.resize-handle' },
+ modifiers: [
+ interact.modifiers.restrictSize({
+ min: { width: 40, height: 40 }
+ }),
+ interact.modifiers.restrictRect({
+ restriction: 'parent'
+ }),
+ interact.modifiers.snap({
+ targets: [
+ interact.snappers.grid({ x: 20, y: 20 })
+ ],
+ range: Infinity,
+ offset: 'parent'
+ })
+ ],
+ listeners: {
+ move: resizeMoveListener,
+ end: onResizeEnd
+ }
+ });
+ }
+
+ return {
+ destroy() {
+ interactable.unset();
+ }
+ };
}
function dragMoveListener(event: any) {
@@ -52,21 +128,84 @@
target.setAttribute('data-y', y);
}
- function onDragEnd(event: any) {
+ function resizeMoveListener(event: any) {
const target = event.target;
+ let x = parseFloat(target.getAttribute('data-x')) || 0;
+ let y = parseFloat(target.getAttribute('data-y')) || 0;
+
+ target.style.width = event.rect.width + 'px';
+ target.style.height = event.rect.height + 'px';
+
+ x += event.deltaRect.left;
+ y += event.deltaRect.top;
+
+ target.style.transform = `translate(${x}px, ${y}px)`;
+
+ target.setAttribute('data-x', x);
+ target.setAttribute('data-y', y);
+ }
+
+ function onDragEnd(event: any) {
+ updateSpaceDimensions(event.target);
+ }
+
+ function onResizeEnd(event: any) {
+ updateSpaceDimensions(event.target);
+ }
+
+ function updateSpaceDimensions(target: any) {
const id = target.getAttribute('data-id');
const x = parseFloat(target.getAttribute('data-x')) || 0;
const y = parseFloat(target.getAttribute('data-y')) || 0;
+ const width = target.offsetWidth;
+ const height = target.offsetHeight;
- // Convert to percentage
- const rect = container.getBoundingClientRect();
- const xPercent = (x / rect.width) * 100;
- const yPercent = (y / rect.height) * 100;
+ if (!containerWidth || !containerHeight) return; // Prevent division by zero
+
+ // If the element is outside the current container, grow it!
+ const right = x + width;
+ const bottom = y + height;
+
+ const oldWidth = containerWidth;
+ const oldHeight = containerHeight;
+ let needsResize = false;
+ if (right > containerWidth - 20) {
+ containerWidth = Math.ceil(right + 40);
+ needsResize = true;
+ }
+ if (bottom > containerHeight - 20) {
+ containerHeight = Math.ceil(bottom + 40);
+ needsResize = true;
+ }
+
+ if (needsResize) {
+ spaces = spaces.map(s => {
+ // Recalculate all percentages based on new container size to keep pixel positions same
+ return {
+ ...s,
+ x: (s.x * oldWidth) / containerWidth,
+ y: (s.y * oldHeight) / containerHeight,
+ width: (s.width * oldWidth) / containerWidth,
+ height: (s.height * oldHeight) / containerHeight
+ };
+ });
+ }
+
+ // Convert current target to percentages based on the (potentially new) container size
+ const xPercent = (x / containerWidth) * 100;
+ const yPercent = (y / containerHeight) * 100;
+ const widthPercent = (width / containerWidth) * 100;
+ const heightPercent = (height / containerHeight) * 100;
+
+ if (isNaN(xPercent) || isNaN(yPercent) || isNaN(widthPercent) || isNaN(heightPercent)) return;
const index = spaces.findIndex(s => s.id === id);
if (index !== -1) {
spaces[index].x = xPercent;
spaces[index].y = yPercent;
+ spaces[index].width = widthPercent;
+ spaces[index].height = heightPercent;
+ spaces = [...spaces];
}
}
@@ -77,12 +216,11 @@
type: item.type,
x: 5,
y: 5,
+ width: item.type === 'DESK' ? 12 : 20, // Default widths as percentage
+ height: item.type === 'DESK' ? 8 : 15, // Default heights as percentage
floorId
};
spaces = [...spaces, newSpace];
-
- // We need to wait for the DOM to update before initializing interact on new element
- setTimeout(() => initInteract(), 0);
}
async function handleFileUpload(event: Event) {
@@ -113,10 +251,14 @@
async function saveLayout() {
saving = true;
try {
- await apiFetch('/spaces/batch-update', {
+ const response = await apiFetch
('/spaces/batch-update', {
method: 'POST',
body: JSON.stringify(spaces)
});
+ // Update local spaces with returned data (especially for new IDs)
+ if (Array.isArray(response)) {
+ spaces = response;
+ }
alert('Layout saved successfully!');
} catch (err) {
console.error('Save failed', err);
@@ -204,15 +346,14 @@