more progress, clipboard

This commit is contained in:
Evert Prants 2023-01-19 17:07:57 +02:00
parent ccfe07a0b8
commit 2dc4256f0f
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
11 changed files with 389 additions and 64 deletions

View File

@ -12,7 +12,7 @@
}"
/>
</div>
<PlannerToolbar>
<PlannerToolbar v-if="editable">
<PlannerTool
v-for="toolItem of toolbar"
:title="toolItem.title"
@ -36,7 +36,7 @@
</PlannerTool>
</PlannerToolbar>
<PlannerSidebars>
<PlannerSidebars v-if="editable">
<PlannerLayerPanel
:layers="localFloorDocument.layers"
@layer-name="commitLayerName"
@ -66,6 +66,7 @@ import { useDebounceFn } from '@vueuse/shared';
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { HousePlanner } from '../../modules/house-planner';
import {
Line,
RepositionEvent,
ToolEvent,
Vec2,
@ -88,20 +89,27 @@ const canvasDim = ref<Vec2>([1920, 1080]);
const canvasPos = ref<Vec2>([0, 0]);
const canvasZoom = ref<number>(1);
const props = defineProps<{
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<string, (e: CustomEvent) => 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<string, (e: CustomEvent) => 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])

View File

@ -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';

View File

@ -1,4 +1,12 @@
export interface RoomListItem {
id: number;
displayName: string;
plan: string;
}
export interface UpsertRoomItem {
id?: number;
canvasObjectId?: number;
displayName: string;
plan: string;
}

View File

@ -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<Vec2[]>((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<Vec2 | null>(
(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;
}

View File

@ -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,
}));
}
}

View File

@ -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<number>(
(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', {

View File

@ -91,19 +91,6 @@ export class LineTool extends CanvasToolBase<LineToolType> {
);
}
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<LineToolType> {
}
const newLineObject: Line = {
id: this.getSequentialId(),
id: this.manager.getSequentialId(),
name: this.defaultName,
type: this.subTool! as LayerObjectType,
visible: true,

View File

@ -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<FloorListItem>
) {
await jfetch(`${BACKEND_URL}/buildings/${building}/floors/${number}`, {
const { data: saveData } = await jfetch<FloorListItem>(
`${BACKEND_URL}/buildings/${building}/floors/${number}`,
{
method: 'PATCH',
headers: {
...authHeader.value,
'Content-Type': 'application/json',
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;
},
body: JSON.stringify(floor),
});
async createRoom(
building: number,
floor: number,
room: UpsertRoomItem
): Promise<UpsertRoomItem> {
const canvasId = room.canvasObjectId;
const { data: returned } = await jfetch<RoomListItem>(
`${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<UpsertRoomItem> {
const canvasId = room.canvasObjectId;
const roomId = room.id;
const { data: returned } = await jfetch<RoomListItem>(
`${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<RoomListItem>(
`${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;
},
},
});

View File

@ -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<Vec2[]>((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
);
}

View File

@ -21,6 +21,14 @@ export default async function jfetch<T = any>(
}
) {
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<string, unknown>),
});

View File

@ -1,5 +1,5 @@
<template>
<div class="relative h-full">
<div class="relative h-full bg-gray-100">
<div
class="absolute top-0 left-0 z-10 rounded-br-md border-b-2 border-r-2 border-gray-200 bg-white px-2 py-2 shadow-lg"
>
@ -37,10 +37,21 @@
</div>
<HousePlanner
v-if="selectedFloorId"
editable
ref="plannerRef"
:key="`planner-${selectedFloorId}`"
:floor-document="floorPlan"
@update="($newValue) => updateDocument($newValue)"
@update="(layers, rooms) => updateDocument(layers, rooms)"
@edited="status = 'Modified'"
/>
<div
class="flex h-full w-full select-none items-center justify-center text-lg"
v-else
>
<span class="font-bold uppercase text-gray-300"
>Choose a floor to edit from the top left</span
>
</div>
</div>
</template>
@ -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<number>();
const selectedFloorId = ref<number>();
const status = ref('No changes');
const plannerRef = ref<InstanceType<typeof HousePlanner>>();
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...';
try {
data = await updateRooms(data, rooms);
// Prevent useless requests
const floorPlan = JSON.stringify(data);
if (currentFloor.value.plan === floorPlan) {
status.value = 'Saved!';
return;
}
await building.saveFloor(
selectedBuildingId.value,
currentFloor.value.number,
{
plan: JSON.stringify(data),
plan: floorPlan,
}
);
status.value = 'Saved!';
} catch (e) {
console.error(`Failed to save floor document: ${(e as Error).stack}`);
status.value = 'Failed to save!';
}
};
onMounted(() => {