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> </div>
<PlannerToolbar> <PlannerToolbar v-if="editable">
<PlannerTool <PlannerTool
v-for="toolItem of toolbar" v-for="toolItem of toolbar"
:title="toolItem.title" :title="toolItem.title"
@ -36,7 +36,7 @@
</PlannerTool> </PlannerTool>
</PlannerToolbar> </PlannerToolbar>
<PlannerSidebars> <PlannerSidebars v-if="editable">
<PlannerLayerPanel <PlannerLayerPanel
:layers="localFloorDocument.layers" :layers="localFloorDocument.layers"
@layer-name="commitLayerName" @layer-name="commitLayerName"
@ -66,6 +66,7 @@ import { useDebounceFn } from '@vueuse/shared';
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'; import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { HousePlanner } from '../../modules/house-planner'; import { HousePlanner } from '../../modules/house-planner';
import { import {
Line,
RepositionEvent, RepositionEvent,
ToolEvent, ToolEvent,
Vec2, Vec2,
@ -88,20 +89,27 @@ const canvasDim = ref<Vec2>([1920, 1080]);
const canvasPos = ref<Vec2>([0, 0]); const canvasPos = ref<Vec2>([0, 0]);
const canvasZoom = ref<number>(1); const canvasZoom = ref<number>(1);
const props = defineProps<{ const props = withDefaults(
floorDocument: FloorDocument; defineProps<{
}>(); floorDocument: FloorDocument;
editable: boolean;
}>(),
{ editable: true }
);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update', document: FloorDocument): void; (e: 'update', document: FloorDocument, rooms: Line[]): void;
(e: 'edited'): void; (e: 'edited'): void;
}>(); }>();
const localFloorDocument = ref(deepUnref(props.floorDocument)); const localFloorDocument = ref(deepUnref(props.floorDocument));
const debouncedUpdate = useDebounceFn( const emitUpdate = () =>
() => emit('update', localFloorDocument.value), emit(
5000 'update',
); localFloorDocument.value,
(module.value.manager?.getAllObjectsOfType('room') || []) as Line[]
);
const debouncedUpdate = useDebounceFn(emitUpdate, 10000);
const toolbar: ToolbarTool[] = [ 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> = { const events: Record<string, (e: CustomEvent) => void> = {
'hpc:update': (e: CustomEvent) => { 'hpc:update': (e: CustomEvent) => {
localFloorDocument.value.layers = deepUnref(module.value.manager!.layers); updateLocalDocument();
emit('edited'); emit('edited');
debouncedUpdate(); debouncedUpdate();
}, },
'hpc:selectionchange': (e: CustomEvent) => { 'hpc:selectionchange': (e: CustomEvent) => {
localFloorDocument.value.layers = deepUnref(module.value.manager!.layers); updateLocalDocument();
emit('edited'); emit('edited');
debouncedUpdate(); debouncedUpdate();
}, },
@ -214,15 +226,28 @@ const events: Record<string, (e: CustomEvent) => void> = {
canvasPos.value = e.detail.position; canvasPos.value = e.detail.position;
canvasZoom.value = e.detail.zoom; canvasZoom.value = e.detail.zoom;
}, },
'hpc:save': (e: CustomEvent) => {
updateLocalDocument();
emit('edited');
emitUpdate();
},
}; };
defineExpose({
updateLocalDocument() {
updateLocalDocument();
return localFloorDocument.value;
},
});
onMounted(() => { onMounted(() => {
module.value.initialize( module.value.initialize(
canvas.value, canvas.value,
deepUnref(localFloorDocument.value.layers), deepUnref(localFloorDocument.value.layers),
[localFloorDocument.value.width, localFloorDocument.value.height], [localFloorDocument.value.width, localFloorDocument.value.height],
canvasPos.value, canvasPos.value,
canvasZoom.value canvasZoom.value,
props.editable
); );
Object.keys(events).forEach((event) => Object.keys(events).forEach((event) =>
canvas.value.addEventListener(event, events[event]) canvas.value.addEventListener(event, events[event])

View File

@ -1,4 +1,4 @@
const env = import.meta.env; const env = import.meta.env;
export const BACKEND_URL = 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 { export interface RoomListItem {
id: number; id: number;
displayName: string; 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 { clamp } from '@vueuse/core';
import extractLinePoints from '../../utils/extract-line-points';
import { HousePlannerCanvasGrid } from './grid'; import { HousePlannerCanvasGrid } from './grid';
import { import {
BezierSegment, BezierSegment,
@ -11,6 +12,7 @@ import {
Vec2, Vec2,
} from './interfaces'; } from './interfaces';
import { HousePlannerCanvasTools } from './tools'; import { HousePlannerCanvasTools } from './tools';
import { LayerObjectType } from './types';
import { import {
vec2Add, vec2Add,
vec2AngleFromOrigin, vec2AngleFromOrigin,
@ -260,6 +262,14 @@ export class HousePlannerCanvas {
: this.mousePosition; : 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) { private keyDownEvent(e: KeyboardEvent) {
if (e.target !== document.body && e.target != null) return; if (e.target !== document.body && e.target != null) return;
this.tools?.onKeyDown(e); this.tools?.onKeyDown(e);
@ -288,15 +298,7 @@ export class HousePlannerCanvas {
} }
private drawRoomText(line: Line) { private drawRoomText(line: Line) {
const points = line.segments const points = extractLinePoints(line);
.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 centerPoint = vec2DivideScalar( const centerPoint = vec2DivideScalar(
points.reduce<Vec2 | null>( points.reduce<Vec2 | null>(
(prev, current) => (prev ? vec2Add(prev, current) : current), (prev, current) => (prev ? vec2Add(prev, current) : current),
@ -458,9 +460,14 @@ export class HousePlannerCanvas {
} }
private pointerDown() { private pointerDown() {
if (!this.tools) return; if (!this.tools) {
this.dragging = true;
return;
}
this.clickedOn = this.tools.getMousedObject(); this.clickedOn = this.tools.getMousedObject();
this.tools.mouseDown(this.clickedOn || undefined); this.tools.mouseDown(this.clickedOn || undefined);
if (!this.clickedOn) { if (!this.clickedOn) {
this.dragging = true; 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 type { HousePlannerCanvas } from './canvas';
import { HousePlannerCanvasClipboard } from './clipboard';
import { HousePlannerCanvasHistory } from './history'; import { HousePlannerCanvasHistory } from './history';
import { import {
History, History,
@ -27,6 +28,7 @@ export class HousePlannerCanvasTools implements ICanvasToolkit {
['cut']: new CutTool(this), ['cut']: new CutTool(this),
}; };
public history = new HousePlannerCanvasHistory(); public history = new HousePlannerCanvasHistory();
public clipboard = new HousePlannerCanvasClipboard();
public lastStrokeWidth = 16; public lastStrokeWidth = 16;
public lastColor = '#000000'; public lastColor = '#000000';
public holdShift = false; public holdShift = false;
@ -243,6 +245,10 @@ export class HousePlannerCanvasTools implements ICanvasToolkit {
} }
onKeyDown(e: KeyboardEvent) { onKeyDown(e: KeyboardEvent) {
if (e.key === 'Shift') {
this.holdShift = true;
}
if (e.key === 'z' && e.ctrlKey) { if (e.key === 'z' && e.ctrlKey) {
e.preventDefault(); e.preventDefault();
this.history.undo(); this.history.undo();
@ -277,8 +283,48 @@ export class HousePlannerCanvasTools implements ICanvasToolkit {
); );
} }
if (e.key === 'Shift') { if (e.key === 'a' && e.ctrlKey) {
this.holdShift = true; 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'); this.setTool('line');
} }
if (e.key === 'Delete' || e.key === 'x') { if (e.key === 'Delete') {
e.preventDefault(); e.preventDefault();
this.deleteSelection(); 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() { private selectedEvent() {
this.canvas.dispatchEvent( this.canvas.dispatchEvent(
new CustomEvent('hpc:selectionchange', { 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() { private startLine() {
if (!this.layer?.visible) return; if (!this.layer?.visible) return;
if (this.drawingLine) { if (this.drawingLine) {
@ -159,7 +146,7 @@ export class LineTool extends CanvasToolBase<LineToolType> {
} }
const newLineObject: Line = { const newLineObject: Line = {
id: this.getSequentialId(), id: this.manager.getSequentialId(),
name: this.defaultName, name: this.defaultName,
type: this.subTool! as LayerObjectType, type: this.subTool! as LayerObjectType,
visible: true, visible: true,

View File

@ -1,9 +1,10 @@
import omit from 'lodash.omit';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useAccessToken } from '../composables/useAccessToken'; import { useAccessToken } from '../composables/useAccessToken';
import { BACKEND_URL } from '../constants'; import { BACKEND_URL } from '../constants';
import { BuildingListItem } from '../interfaces/building.interfaces'; import { BuildingListItem } from '../interfaces/building.interfaces';
import { FloorListItem } from '../interfaces/floor.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'; import jfetch from '../utils/jfetch';
const { authHeader } = useAccessToken(); const { authHeader } = useAccessToken();
@ -35,20 +36,105 @@ export const useBuildingStore = defineStore('building', {
number: number, number: number,
floor: Partial<FloorListItem> floor: Partial<FloorListItem>
) { ) {
await jfetch(`${BACKEND_URL}/buildings/${building}/floors/${number}`, { const { data: saveData } = await jfetch<FloorListItem>(
method: 'PATCH', `${BACKEND_URL}/buildings/${building}/floors/${number}`,
headers: { {
...authHeader.value, method: 'PATCH',
'Content-Type': 'application/json', headers: authHeader.value,
}, body: floor,
body: JSON.stringify(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<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( async upsertFloorRooms(
building: number, building: number,
floorNo: number, floorNo: number,
rooms: RoomListItem[], rooms: UpsertRoomItem[]
removedRooms: number[] ) {
) {}, 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; 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, { const response = await fetch(url, {
...(opts as Record<string, unknown>), ...(opts as Record<string, unknown>),
}); });

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="relative h-full"> <div class="relative h-full bg-gray-100">
<div <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" 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> </div>
<HousePlanner <HousePlanner
v-if="selectedFloorId" v-if="selectedFloorId"
editable
ref="plannerRef"
:key="`planner-${selectedFloorId}`"
:floor-document="floorPlan" :floor-document="floorPlan"
@update="($newValue) => updateDocument($newValue)" @update="(layers, rooms) => updateDocument(layers, rooms)"
@edited="status = 'Modified'" @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> </div>
</template> </template>
@ -50,13 +61,17 @@ import { computed, onMounted, ref } from 'vue';
import { defaultRoomData } from '../components/house-planner/helpers/default-room'; import { defaultRoomData } from '../components/house-planner/helpers/default-room';
import HousePlanner from '../components/house-planner/HousePlanner.vue'; import HousePlanner from '../components/house-planner/HousePlanner.vue';
import { FloorDocument } from '../components/house-planner/interfaces/floor-document.interface'; 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 { useBuildingStore } from '../store/building.store';
import extractLinePoints from '../utils/extract-line-points';
const building = useBuildingStore(); const building = useBuildingStore();
const { buildings, floors } = storeToRefs(building); const { buildings, floors } = storeToRefs(building);
const selectedBuildingId = ref<number>(); const selectedBuildingId = ref<number>();
const selectedFloorId = ref<number>(); const selectedFloorId = ref<number>();
const status = ref('No changes'); const status = ref('No changes');
const plannerRef = ref<InstanceType<typeof HousePlanner>>();
const currentFloor = computed( const currentFloor = computed(
() => () =>
@ -68,33 +83,91 @@ const floorPlan = computed(
currentFloor.value && currentFloor.value &&
Object.assign({ Object.assign({
...defaultRoomData, ...defaultRoomData,
...JSON.parse(currentFloor.value.plan || '{}'),
id: selectedFloorId.value, id: selectedFloorId.value,
...JSON.parse(currentFloor.value.plan),
}) })
); );
const buildingSelected = async () => { const buildingSelected = async () => {
if (selectedBuildingId.value == null) return; if (selectedBuildingId.value == null) return;
selectedFloorId.value = undefined;
await building.getFloors(selectedBuildingId.value); await building.getFloors(selectedBuildingId.value);
}; };
const updateDocument = async (data: FloorDocument) => { const updateRooms = async (data: FloorDocument, rooms: Line[]) => {
if ( if (
!selectedBuildingId.value || !selectedBuildingId.value ||
!selectedFloorId.value || !selectedFloorId.value ||
!currentFloor.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; return;
status.value = 'Saving...'; status.value = 'Saving...';
await building.saveFloor( try {
selectedBuildingId.value, data = await updateRooms(data, rooms);
currentFloor.value.number,
{ // Prevent useless requests
plan: JSON.stringify(data), 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(() => { onMounted(() => {