diff --git a/src/components/house-planner/HousePlanner.vue b/src/components/house-planner/HousePlanner.vue index da67010..3cc1783 100644 --- a/src/components/house-planner/HousePlanner.vue +++ b/src/components/house-planner/HousePlanner.vue @@ -12,7 +12,7 @@ }" /> - + - + ([1920, 1080]); const canvasPos = ref([0, 0]); const canvasZoom = ref(1); -const props = defineProps<{ - floorDocument: FloorDocument; -}>(); +const props = withDefaults( + defineProps<{ + floorDocument: FloorDocument; + editable: boolean; + }>(), + { editable: true } +); const emit = defineEmits<{ - (e: 'update', document: FloorDocument): void; + (e: 'update', document: FloorDocument, rooms: Line[]): void; (e: 'edited'): void; }>(); const localFloorDocument = ref(deepUnref(props.floorDocument)); -const debouncedUpdate = useDebounceFn( - () => emit('update', localFloorDocument.value), - 5000 -); +const emitUpdate = () => + emit( + 'update', + localFloorDocument.value, + (module.value.manager?.getAllObjectsOfType('room') || []) as Line[] + ); +const debouncedUpdate = useDebounceFn(emitUpdate, 10000); const toolbar: ToolbarTool[] = [ { @@ -195,14 +203,18 @@ const updateObjectProperty = ( }); }; +const updateLocalDocument = () => { + localFloorDocument.value.layers = deepUnref(module.value.manager!.layers); +}; + const events: Record void> = { 'hpc:update': (e: CustomEvent) => { - localFloorDocument.value.layers = deepUnref(module.value.manager!.layers); + updateLocalDocument(); emit('edited'); debouncedUpdate(); }, 'hpc:selectionchange': (e: CustomEvent) => { - localFloorDocument.value.layers = deepUnref(module.value.manager!.layers); + updateLocalDocument(); emit('edited'); debouncedUpdate(); }, @@ -214,15 +226,28 @@ const events: Record void> = { canvasPos.value = e.detail.position; canvasZoom.value = e.detail.zoom; }, + 'hpc:save': (e: CustomEvent) => { + updateLocalDocument(); + emit('edited'); + emitUpdate(); + }, }; +defineExpose({ + updateLocalDocument() { + updateLocalDocument(); + return localFloorDocument.value; + }, +}); + onMounted(() => { module.value.initialize( canvas.value, deepUnref(localFloorDocument.value.layers), [localFloorDocument.value.width, localFloorDocument.value.height], canvasPos.value, - canvasZoom.value + canvasZoom.value, + props.editable ); Object.keys(events).forEach((event) => canvas.value.addEventListener(event, events[event]) diff --git a/src/constants/index.ts b/src/constants/index.ts index e89bf99..4ee42ef 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,4 @@ const env = import.meta.env; export const BACKEND_URL = - (env.BACKEND_URL as string) || 'http://localhost:3000'; + (env.VITE_BACKEND_URL as string) || 'http://localhost:3000'; diff --git a/src/interfaces/room.interfaces.ts b/src/interfaces/room.interfaces.ts index 02f3bef..244403d 100644 --- a/src/interfaces/room.interfaces.ts +++ b/src/interfaces/room.interfaces.ts @@ -1,4 +1,12 @@ export interface RoomListItem { id: number; displayName: string; + plan: string; +} + +export interface UpsertRoomItem { + id?: number; + canvasObjectId?: number; + displayName: string; + plan: string; } diff --git a/src/modules/house-planner/canvas.ts b/src/modules/house-planner/canvas.ts index e42cf12..0fdb538 100644 --- a/src/modules/house-planner/canvas.ts +++ b/src/modules/house-planner/canvas.ts @@ -1,4 +1,5 @@ import { clamp } from '@vueuse/core'; +import extractLinePoints from '../../utils/extract-line-points'; import { HousePlannerCanvasGrid } from './grid'; import { BezierSegment, @@ -11,6 +12,7 @@ import { Vec2, } from './interfaces'; import { HousePlannerCanvasTools } from './tools'; +import { LayerObjectType } from './types'; import { vec2Add, vec2AngleFromOrigin, @@ -260,6 +262,14 @@ export class HousePlannerCanvas { : this.mousePosition; } + getAllObjectsOfType(type: LayerObjectType) { + const objects: LayerObject[] = []; + this.layers.forEach((layer) => { + objects.push(...layer.contents.filter((object) => object.type === type)); + }); + return objects; + } + private keyDownEvent(e: KeyboardEvent) { if (e.target !== document.body && e.target != null) return; this.tools?.onKeyDown(e); @@ -288,15 +298,7 @@ export class HousePlannerCanvas { } private drawRoomText(line: Line) { - const points = line.segments - .reduce((list, segment) => { - if (segment.start) return [...list, segment.start, segment.end]; - return [...list, segment.end]; - }, []) - .filter( - (vec, index, arry) => - arry.findIndex((point) => vec2Equals(point, vec)) === index - ); + const points = extractLinePoints(line); const centerPoint = vec2DivideScalar( points.reduce( (prev, current) => (prev ? vec2Add(prev, current) : current), @@ -458,9 +460,14 @@ export class HousePlannerCanvas { } private pointerDown() { - if (!this.tools) return; + if (!this.tools) { + this.dragging = true; + return; + } + this.clickedOn = this.tools.getMousedObject(); this.tools.mouseDown(this.clickedOn || undefined); + if (!this.clickedOn) { this.dragging = true; } diff --git a/src/modules/house-planner/clipboard.ts b/src/modules/house-planner/clipboard.ts new file mode 100644 index 0000000..1d91826 --- /dev/null +++ b/src/modules/house-planner/clipboard.ts @@ -0,0 +1,29 @@ +import deepUnref from '../../utils/deep-unref'; +import { LayerObject } from './interfaces'; + +export class HousePlannerCanvasClipboard { + public storedObjects: LayerObject[] = []; + + storeToClipboard(items: LayerObject[]) { + this.storedObjects = [ + ...items.map((item) => { + const itemCopy = { + ...deepUnref(item), + visible: true, + name: item.name + ' Copy', + }; + delete itemCopy.databaseId; + return itemCopy; + }), + ]; + } + + getFromClipboard(newId: number, selected = true): LayerObject[] { + const newObjects = deepUnref(this.storedObjects); + return newObjects.map((item, index) => ({ + ...item, + id: newId + index, + selected, + })); + } +} diff --git a/src/modules/house-planner/tools.ts b/src/modules/house-planner/tools.ts index 18d1dee..ed2a80f 100644 --- a/src/modules/house-planner/tools.ts +++ b/src/modules/house-planner/tools.ts @@ -1,4 +1,5 @@ import type { HousePlannerCanvas } from './canvas'; +import { HousePlannerCanvasClipboard } from './clipboard'; import { HousePlannerCanvasHistory } from './history'; import { History, @@ -27,6 +28,7 @@ export class HousePlannerCanvasTools implements ICanvasToolkit { ['cut']: new CutTool(this), }; public history = new HousePlannerCanvasHistory(); + public clipboard = new HousePlannerCanvasClipboard(); public lastStrokeWidth = 16; public lastColor = '#000000'; public holdShift = false; @@ -243,6 +245,10 @@ export class HousePlannerCanvasTools implements ICanvasToolkit { } onKeyDown(e: KeyboardEvent) { + if (e.key === 'Shift') { + this.holdShift = true; + } + if (e.key === 'z' && e.ctrlKey) { e.preventDefault(); this.history.undo(); @@ -277,8 +283,48 @@ export class HousePlannerCanvasTools implements ICanvasToolkit { ); } - if (e.key === 'Shift') { - this.holdShift = true; + if (e.key === 'a' && e.ctrlKey) { + e.preventDefault(); + this.selectAll(); + } + + if (e.key === 'c' && e.ctrlKey) { + e.preventDefault(); + if (this.selectedObjects.length) + this.clipboard.storeToClipboard(this.selectedObjects); + } + + if (e.key === 'x' && e.ctrlKey) { + e.preventDefault(); + if (this.selectedObjects.length && this.selectedLayer) { + this.clipboard.storeToClipboard(this.selectedObjects); + this.deleteSelection(); + } + } + + if (e.key === 'v' && e.ctrlKey) { + e.preventDefault(); + if (!this.selectedLayer) { + return; + } + + const items = this.clipboard.getFromClipboard( + this.getSequentialId(), + true + ); + + if (items.length) { + this.selectedLayer?.contents.push(...items); + this.selectObject(null); + this.selectedObjects = items; + this.tool?.selectionChanged(this.selectedObjects); + this.manager.draw(); + } + } + + if (e.key === 's' && e.ctrlKey) { + e.preventDefault(); + this.canvas.dispatchEvent(new CustomEvent('hpc:save')); } } @@ -309,7 +355,7 @@ export class HousePlannerCanvasTools implements ICanvasToolkit { this.setTool('line'); } - if (e.key === 'Delete' || e.key === 'x') { + if (e.key === 'Delete') { e.preventDefault(); this.deleteSelection(); } @@ -340,6 +386,48 @@ export class HousePlannerCanvasTools implements ICanvasToolkit { ); } + selectAll() { + if (!this.selectedLayer) return; + if ( + this.selectedLayer.contents + .filter((item) => item.visible) + .every((item) => item.selected) + ) { + for (const item of this.selectedLayer.contents) { + item.selected = false; + } + this.selectedObjects.length = 0; + this.selectedEvent(); + this.tool?.selectionChanged([]); + this.manager.draw(); + return; + } + + const visibleItems = this.selectedLayer.contents.filter( + (item) => item.visible + ); + for (const item of visibleItems) { + item.selected = true; + } + this.selectedObjects = [...visibleItems]; + this.tool?.selectionChanged(this.selectedObjects); + this.selectedEvent(); + this.manager.draw(); + } + + getSequentialId() { + return ( + this.manager.layers.reduce((total, current) => { + const biggestInLayer = current.contents.reduce( + (biggest2, current2) => + biggest2 > current2.id ? biggest2 : current2.id, + 0 + ); + return total > biggestInLayer ? total : biggestInLayer; + }, 0) + 1 + ); + } + private selectedEvent() { this.canvas.dispatchEvent( new CustomEvent('hpc:selectionchange', { diff --git a/src/modules/house-planner/tools/line.ts b/src/modules/house-planner/tools/line.ts index 04f2091..1aa5273 100644 --- a/src/modules/house-planner/tools/line.ts +++ b/src/modules/house-planner/tools/line.ts @@ -91,19 +91,6 @@ export class LineTool extends CanvasToolBase { ); } - private getSequentialId() { - return ( - this.renderer.layers.reduce( - (total, current) => - current.contents.reduce( - (total2, current2) => current2.id + total2, - 0 - ) + total, - 0 - ) + 1 - ); - } - private startLine() { if (!this.layer?.visible) return; if (this.drawingLine) { @@ -159,7 +146,7 @@ export class LineTool extends CanvasToolBase { } const newLineObject: Line = { - id: this.getSequentialId(), + id: this.manager.getSequentialId(), name: this.defaultName, type: this.subTool! as LayerObjectType, visible: true, diff --git a/src/store/building.store.ts b/src/store/building.store.ts index 8688029..8066f74 100644 --- a/src/store/building.store.ts +++ b/src/store/building.store.ts @@ -1,9 +1,10 @@ +import omit from 'lodash.omit'; import { defineStore } from 'pinia'; import { useAccessToken } from '../composables/useAccessToken'; import { BACKEND_URL } from '../constants'; import { BuildingListItem } from '../interfaces/building.interfaces'; import { FloorListItem } from '../interfaces/floor.interfaces'; -import { RoomListItem } from '../interfaces/room.interfaces'; +import { RoomListItem, UpsertRoomItem } from '../interfaces/room.interfaces'; import jfetch from '../utils/jfetch'; const { authHeader } = useAccessToken(); @@ -35,20 +36,105 @@ export const useBuildingStore = defineStore('building', { number: number, floor: Partial ) { - await jfetch(`${BACKEND_URL}/buildings/${building}/floors/${number}`, { - method: 'PATCH', - headers: { - ...authHeader.value, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(floor), - }); + const { data: saveData } = await jfetch( + `${BACKEND_URL}/buildings/${building}/floors/${number}`, + { + method: 'PATCH', + headers: authHeader.value, + body: floor, + } + ); + + // Update local state + const currentFloor = this.floors.find( + (existing) => existing.number === number + ); + if (currentFloor) { + Object.assign(currentFloor, saveData); + } + + return saveData; + }, + async createRoom( + building: number, + floor: number, + room: UpsertRoomItem + ): Promise { + const canvasId = room.canvasObjectId; + const { data: returned } = await jfetch( + `${BACKEND_URL}/buildings/${building}/floors/${floor}/rooms`, + { + method: 'POST', + headers: authHeader.value, + body: omit(room, 'canvasObjectId'), + } + ); + return { ...returned, canvasObjectId: canvasId }; + }, + async updateRoom( + building: number, + room: UpsertRoomItem + ): Promise { + const canvasId = room.canvasObjectId; + const roomId = room.id; + const { data: returned } = await jfetch( + `${BACKEND_URL}/buildings/${building}/rooms/${roomId}`, + { + method: 'PATCH', + headers: authHeader.value, + body: omit(room, ['canvasObjectId', 'id']), + } + ); + return { ...returned, canvasObjectId: canvasId }; + }, + async deleteRoom(building: number, roomId: number) { + await jfetch( + `${BACKEND_URL}/buildings/${building}/rooms/${roomId}`, + { + method: 'DELETE', + headers: authHeader.value, + } + ); }, async upsertFloorRooms( building: number, floorNo: number, - rooms: RoomListItem[], - removedRooms: number[] - ) {}, + rooms: UpsertRoomItem[] + ) { + const currentBuilding = this.buildings.find(({ id }) => building === id); + const currentFloor = this.floors.find(({ number }) => number === floorNo); + if (!currentBuilding || !currentFloor) return; + + // Gather data + const roomsToCreate = rooms.filter( + (room) => room.id === undefined && room.displayName && room.plan + ); + const roomsToUpdate = rooms.filter((room) => { + if (!room.id) return false; + const exists = currentFloor.rooms.find( + (existing) => existing.id === room.id + ); + if (!exists) return false; + return ( + exists.displayName !== room.displayName || exists.plan !== room.plan + ); + }); + const roomsToDelete = currentFloor.rooms.filter( + (room) => !rooms.find((provided) => provided.id === room.id) + ); + + // Do requests + const createdRooms = await Promise.all( + roomsToCreate.map((room) => this.createRoom(building, floorNo, room)) + ); + await Promise.allSettled( + roomsToUpdate.map((room) => this.updateRoom(building, room)) + ); + await Promise.allSettled( + roomsToDelete.map((room) => this.deleteRoom(building, room.id)) + ); + + return createdRooms; + }, }, }); diff --git a/src/utils/extract-line-points.ts b/src/utils/extract-line-points.ts new file mode 100644 index 0000000..ff996de --- /dev/null +++ b/src/utils/extract-line-points.ts @@ -0,0 +1,14 @@ +import { Line, Vec2 } from '../modules/house-planner/interfaces'; +import { vec2Equals } from '../modules/house-planner/utils'; + +export default function extractLinePoints(line: Line) { + return line.segments + .reduce((list, segment) => { + if (segment.start) return [...list, segment.start, segment.end]; + return [...list, segment.end]; + }, []) + .filter( + (vec, index, arry) => + arry.findIndex((point) => vec2Equals(point, vec)) === index + ); +} diff --git a/src/utils/jfetch.ts b/src/utils/jfetch.ts index 5573a23..017d90d 100644 --- a/src/utils/jfetch.ts +++ b/src/utils/jfetch.ts @@ -21,6 +21,14 @@ export default async function jfetch( } ) { const { returnType } = opts; + if (opts['body'] && typeof opts['body'] === 'object') { + opts['body'] = JSON.stringify(opts['body']); + opts['headers'] = { + ...(opts['headers'] || {}), + 'Content-Type': 'application/json', + }; + } + const response = await fetch(url, { ...(opts as Record), }); diff --git a/src/views/HousePlanner.vue b/src/views/HousePlanner.vue index a015d0d..cb5f963 100644 --- a/src/views/HousePlanner.vue +++ b/src/views/HousePlanner.vue @@ -1,5 +1,5 @@ @@ -50,13 +61,17 @@ import { computed, onMounted, ref } from 'vue'; import { defaultRoomData } from '../components/house-planner/helpers/default-room'; import HousePlanner from '../components/house-planner/HousePlanner.vue'; import { FloorDocument } from '../components/house-planner/interfaces/floor-document.interface'; +import { UpsertRoomItem } from '../interfaces/room.interfaces'; +import { Line } from '../modules/house-planner/interfaces'; import { useBuildingStore } from '../store/building.store'; +import extractLinePoints from '../utils/extract-line-points'; const building = useBuildingStore(); const { buildings, floors } = storeToRefs(building); const selectedBuildingId = ref(); const selectedFloorId = ref(); const status = ref('No changes'); +const plannerRef = ref>(); const currentFloor = computed( () => @@ -68,33 +83,91 @@ const floorPlan = computed( currentFloor.value && Object.assign({ ...defaultRoomData, + ...JSON.parse(currentFloor.value.plan || '{}'), id: selectedFloorId.value, - ...JSON.parse(currentFloor.value.plan), }) ); const buildingSelected = async () => { if (selectedBuildingId.value == null) return; + selectedFloorId.value = undefined; await building.getFloors(selectedBuildingId.value); }; -const updateDocument = async (data: FloorDocument) => { +const updateRooms = async (data: FloorDocument, rooms: Line[]) => { if ( !selectedBuildingId.value || !selectedFloorId.value || !currentFloor.value + ) + return data; + + const extractedRooms: UpsertRoomItem[] = rooms.map((room) => ({ + id: room.databaseId, + canvasObjectId: room.id, + displayName: room.name, + plan: JSON.stringify({ polygon: extractLinePoints(room) }), + })); + + if (!extractedRooms.length) { + return data; + } + + const createdRooms = await building.upsertFloorRooms( + selectedBuildingId.value, + currentFloor.value.number, + extractedRooms + ); + + if (createdRooms?.length) { + createdRooms.forEach((room) => { + if (!room.canvasObjectId || !room.id) return; + rooms.forEach((existing) => { + if (existing.id !== room.canvasObjectId) return; + existing.databaseId = room.id; + }); + }); + + if (plannerRef.value) { + data = plannerRef.value.updateLocalDocument(); + } + } + + return data; +}; + +const updateDocument = async (data: FloorDocument, rooms: Line[]) => { + if ( + !selectedBuildingId.value || + !selectedFloorId.value || + !currentFloor.value || + status.value === 'Saving...' ) return; status.value = 'Saving...'; - await building.saveFloor( - selectedBuildingId.value, - currentFloor.value.number, - { - plan: JSON.stringify(data), + try { + data = await updateRooms(data, rooms); + + // Prevent useless requests + const floorPlan = JSON.stringify(data); + if (currentFloor.value.plan === floorPlan) { + status.value = 'Saved!'; + return; } - ); - status.value = 'Saved!'; + + await building.saveFloor( + selectedBuildingId.value, + currentFloor.value.number, + { + plan: floorPlan, + } + ); + status.value = 'Saved!'; + } catch (e) { + console.error(`Failed to save floor document: ${(e as Error).stack}`); + status.value = 'Failed to save!'; + } }; onMounted(() => {