editor progress

This commit is contained in:
Evert Prants 2023-01-17 20:32:15 +02:00
parent e2cf0a1be9
commit caa34afd3e
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
12 changed files with 530 additions and 16 deletions

View File

@ -22,6 +22,20 @@
</template> </template>
</PlannerTool> </PlannerTool>
</PlannerToolbar> </PlannerToolbar>
<PlannerSidebars>
<PlannerLayerPanel
:layers="serializedLayers"
@layer-name="commitLayerName"
@object-name="commitObjectName"
@select-object="clickedOnObject"
@select-layer="clickedOnLayer"
/>
<PlannerPropertyPanel
:layers="serializedLayers"
@update="updateObjectProperty"
/>
</PlannerSidebars>
</div> </div>
</template> </template>
@ -32,13 +46,24 @@ import {
HomeIcon, HomeIcon,
ArrowsPointingOutIcon, ArrowsPointingOutIcon,
ArrowDownOnSquareIcon, ArrowDownOnSquareIcon,
Square3Stack3DIcon,
} from '@heroicons/vue/24/outline'; } from '@heroicons/vue/24/outline';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
import type { Component } from 'vue';
import { onMounted, ref, shallowRef } from 'vue'; import { onMounted, ref, shallowRef } from 'vue';
import { HousePlanner } from '../../modules/house-planner'; import { HousePlanner } from '../../modules/house-planner';
import { Layer, ToolEvent } from '../../modules/house-planner/interfaces'; import { Layer, ToolEvent } from '../../modules/house-planner/interfaces';
import { SubToolType, ToolType } from '../../modules/house-planner/types'; import {
LayerObjectType,
SubToolType,
ToolType,
} from '../../modules/house-planner/types';
import deepUnref from '../../utils/deep-unref';
import { ToolbarTool } from './interfaces/toolbar.interfaces'; import { ToolbarTool } from './interfaces/toolbar.interfaces';
import PlannerLayerPanel from './PlannerLayerPanel.vue';
import PlannerPropertyPanel from './PlannerPropertyPanel.vue';
import PlannerSidebar from './PlannerSidebar.vue';
import PlannerSidebars from './PlannerSidebars.vue';
import PlannerTool from './PlannerTool.vue'; import PlannerTool from './PlannerTool.vue';
import PlannerToolbar from './PlannerToolbar.vue'; import PlannerToolbar from './PlannerToolbar.vue';
@ -50,21 +75,21 @@ const serializedLayers = useSessionStorage<Layer[]>(
'roomData', 'roomData',
[ [
{ {
index: 0, id: 1,
name: 'Base',
color: '#00ddff',
contents: [],
visible: true,
active: true,
},
{
index: 1,
name: 'Rooms', name: 'Rooms',
color: '#00ddff', color: '#00ddff',
contents: [], contents: [],
visible: true, visible: true,
active: false, active: false,
}, },
{
id: 0,
name: 'Base',
color: '#00ddff',
contents: [],
visible: true,
active: true,
},
], ],
{ writeDefaults: false } { writeDefaults: false }
); );
@ -110,15 +135,49 @@ const selectTool = (newTool: ToolType, newSubTool?: SubToolType) => {
module.value.manager?.tools.setTool(newTool, newSubTool); module.value.manager?.tools.setTool(newTool, newSubTool);
}; };
const commitObjectName = (layerId: number, objectId: number, name: string) => {
module.value.manager?.updateObjectProperties(layerId, objectId, { name });
};
const commitLayerName = (layerId: number, name: string) => {
module.value.manager?.updateLayerProperties(layerId, { name });
};
const clickedOnObject = (layerId: number, objectId: number, add?: boolean) => {
const object = module.value.manager?.getLayerObjectById(layerId, objectId);
if (!object) return;
module.value.manager?.tools.selectObject(object, add);
};
const clickedOnLayer = (layerId: number) => {
const layer = module.value.manager?.getLayerById(layerId);
if (!layer) return;
module.value.manager?.tools.selectLayer(layer);
};
const updateObjectProperty = (
layerId: number,
objectId: number,
key: string,
value: unknown
) => {
module.value.manager?.updateObjectProperties(layerId, objectId, {
[key]: value,
});
};
onMounted(() => { onMounted(() => {
const cleanUp = module.value.initialize( const cleanUp = module.value.initialize(
canvas.value, canvas.value,
JSON.parse(JSON.stringify(serializedLayers.value)) deepUnref(serializedLayers.value)
); );
const events: Record<string, (e: CustomEvent) => void> = { const events: Record<string, (e: CustomEvent) => void> = {
'hpc:update': (e: CustomEvent) => { 'hpc:update': (e: CustomEvent) => {
serializedLayers.value = module.value.manager!.layers; serializedLayers.value = deepUnref(module.value.manager!.layers);
},
'hpc:selectionchange': (e: CustomEvent) => {
serializedLayers.value = deepUnref(module.value.manager!.layers);
}, },
'hpc:tool': (e: CustomEvent<ToolEvent>) => { 'hpc:tool': (e: CustomEvent<ToolEvent>) => {
tool.value = e.detail.primary; tool.value = e.detail.primary;

View File

@ -0,0 +1,98 @@
<template>
<PlannerSidebar title="Layers">
<div class="bg-white-50 flex flex-col">
<template v-for="layer of layers">
<button
@dblclick="editingLayerName = layer.id"
@click="!editingLayerName && emit('selectLayer', layer.id)"
:class="[
layer.active ? 'bg-blue-50' : '',
'flex w-full flex-row items-center justify-start space-x-2 px-2 py-2',
]"
>
<Square3Stack3DIcon class="h-4 w-4" />
<input
v-model="layer.name"
v-if="editingLayerName === layer.id"
@blur="commitLayerName(layer.name)"
@keypress.enter="commitLayerName(layer.name)"
/>
<span v-else>{{ layer.name }}</span>
</button>
<div class="flex flex-col bg-gray-50" v-if="layer.active">
<div v-for="object of layer.contents">
<button
@dblclick="editingObjectName = object.id"
@click="
(e) =>
!editingObjectName &&
emit('selectObject', layer.id, object.id, e.shiftKey)
"
:class="[
object.selected ? 'bg-blue-100' : '',
'flex w-full flex-row items-center justify-start space-x-2 px-2 py-2 pl-6',
]"
>
<component :is="objectTypeIcons[object.type]" class="h-4 w-4" />
<input
v-model="object.name"
v-if="editingObjectName === object.id"
@blur="commitObjectName(layer.id, object.name)"
@keypress.enter="commitObjectName(layer.id, object.name)"
/>
<span v-else>{{ object.name }}</span>
</button>
</div>
</div>
</template>
</div>
</PlannerSidebar>
</template>
<script setup lang="ts">
import {
PencilSquareIcon,
PencilIcon,
HomeIcon,
ArrowDownOnSquareIcon,
Square3Stack3DIcon,
} from '@heroicons/vue/24/outline';
import type { Component } from 'vue';
import { ref } from 'vue';
import PlannerSidebar from './PlannerSidebar.vue';
import { Layer } from '../../modules/house-planner/interfaces';
import { LayerObjectType } from '../../modules/house-planner/types';
const objectTypeIcons: Record<LayerObjectType, Component> = {
line: PencilIcon,
room: HomeIcon,
curve: ArrowDownOnSquareIcon,
object: PencilSquareIcon,
};
const props = defineProps<{
layers: Layer[];
}>();
const emit = defineEmits<{
(e: 'layerName', layer: number, name: string): void;
(e: 'objectName', layer: number, object: number, name: string): void;
(e: 'selectLayer', layer: number): void;
(e: 'selectObject', layer: number, object: number, add?: boolean): void;
}>();
const editingLayerName = ref<number | null>(null);
const editingObjectName = ref<number | null>(null);
const commitObjectName = (layerId: number, name: string) => {
if (editingObjectName.value == null) return;
emit('objectName', layerId, editingObjectName.value, name);
editingObjectName.value = null;
};
const commitLayerName = (name: string) => {
if (editingLayerName.value == null) return;
emit('layerName', editingLayerName.value, name);
editingLayerName.value = null;
};
</script>

View File

@ -0,0 +1,153 @@
<template>
<PlannerSidebar title="Properties">
<div
class="bg-white-50 flex flex-col"
v-if="selectedObject && applicableProperties"
>
<div
class="grid grid-cols-2 px-2 py-2"
v-for="prop of applicableProperties.properties"
>
<label :for="`${prop.key}-${selectedObject.id}`">{{
prop.title
}}</label>
<input
v-if="prop.type === 'string'"
:id="`${prop.key}-${selectedObject.id}`"
type="text"
:value="selectedObject[prop.key as keyof typeof selectedObject]"
@input="updateProp(prop, ($event.target as HTMLInputElement)?.value)"
/>
<input
v-else-if="prop.type === 'color'"
:id="`${prop.key}-${selectedObject.id}`"
type="color"
:value="selectedObject[prop.key as keyof typeof selectedObject]"
@change="updateProp(prop, ($event.target as HTMLInputElement)?.value)"
/>
<input
v-else-if="prop.type === 'number'"
:id="`${prop.key}-${selectedObject.id}`"
type="number"
:value="selectedObject[prop.key as keyof typeof selectedObject]"
@input="updateProp(prop, ($event.target as HTMLInputElement)?.value)"
/>
<input
v-else-if="prop.type === 'boolean'"
:id="`${prop.key}-${selectedObject.id}`"
type="checkbox"
:checked="(selectedObject[prop.key as keyof typeof selectedObject] as boolean)"
@input="
updateProp(prop, ($event.target as HTMLInputElement)?.checked)
"
/>
<select
v-else-if="prop.type === 'select'"
:id="`${prop.key}-${selectedObject.id}`"
:value="selectedObject[prop.key as keyof typeof selectedObject]"
@input="updateProp(prop, ($event.target as HTMLInputElement)?.value)"
>
<option v-for="option of prop.options" :value="option.value">
{{ option.title }}
</option>
</select>
</div>
</div>
</PlannerSidebar>
</template>
<script setup lang="ts">
import PlannerSidebar from './PlannerSidebar.vue';
import { Layer } from '../../modules/house-planner/interfaces';
import {
ObjectProperties,
ObjectProperty,
} from './interfaces/properties.interfaces';
import { computed } from 'vue';
const props = defineProps<{
layers: Layer[];
}>();
const emit = defineEmits<{
(
e: 'update',
layerId: number,
objectId: number,
key: string,
value: unknown
): void;
}>();
const commonProps: ObjectProperty[] = [
{ key: 'name', title: 'Name', type: 'string' },
{ key: 'visible', title: 'Visible', type: 'boolean', groupable: true },
];
const lineProps: ObjectProperty[] = [
...commonProps,
{ key: 'width', title: 'Line Width', type: 'number', groupable: true },
{ key: 'color', title: 'Color', type: 'color', groupable: true },
{
key: 'lineCap',
title: 'Line Cap',
type: 'select',
groupable: true,
options: [
{ value: undefined, title: '' },
{ value: 'butt', title: 'Butt' },
{ value: 'round', title: 'Round' },
{ value: 'square', title: 'Square' },
],
},
{ key: 'closed', title: 'Closed', type: 'boolean', groupable: true },
];
const objectTypeProperties: ObjectProperties[] = [
{
type: 'line',
properties: lineProps,
},
{
type: 'curve',
properties: lineProps,
},
{
type: 'room',
properties: lineProps,
},
{
type: 'object',
properties: commonProps,
},
];
const currentLayer = computed(() =>
props.layers.find((layer) => layer.active && layer.visible)
);
// TODO multi edit
const selectedObject = computed(
() => currentLayer.value?.contents?.filter((obj) => obj.selected)[0]
);
const applicableProperties = computed(
() =>
selectedObject.value &&
objectTypeProperties.find(
(prop) => prop.type === selectedObject.value?.type
)
);
const updateProp = (prop: ObjectProperty, value: unknown) => {
if (!currentLayer.value || !selectedObject.value) return;
if (prop.type === 'number') value = parseFloat(value as string);
emit(
'update',
currentLayer.value!.id,
selectedObject.value!.id,
prop.key,
value
);
};
</script>

View File

@ -0,0 +1,51 @@
<template>
<div class="relative flex flex-row overflow-hidden px-2 pb-4 pr-0">
<button
:class="[
open ? 'bg-gray-200' : 'bg-white',
'h-8 rounded-tl-md rounded-bl-md px-2 py-2 ring-1 ring-black ring-opacity-5',
]"
@click="() => (open = !open)"
>
<ChevronDoubleRightIcon class="h-4 w-4" v-if="open" />
<ChevronDoubleLeftIcon class="h-4 w-4" v-else />
</button>
<Transition
enter-active-class="transition-max-width ease-out duration-200 overflow-hidden"
enter-from-class="max-w-0"
enter-to-class="max-w-xs"
leave-active-class="transition-max-width ease-in duration-150 overflow-hidden"
leave-from-class="max-w-xs"
leave-to-class="max-w-0"
>
<div
class="h-screen max-h-96 rounded-bl-md bg-white shadow-lg ring-1 ring-black ring-opacity-5"
v-if="open"
>
<div class="w-screen max-w-xs">
<slot name="header">
<div
class="select-none bg-gray-200 px-2 py-1 font-bold uppercase text-gray-400"
>
{{ title }}
</div>
</slot>
<slot></slot>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import {
ChevronDoubleRightIcon,
ChevronDoubleLeftIcon,
} from '@heroicons/vue/24/outline';
import { ref } from 'vue';
const props = defineProps<{
title: string;
}>();
const open = ref(true);
</script>

View File

@ -0,0 +1,7 @@
<template>
<div
class="absolute right-0 top-0 z-10 mt-4 flex flex-col items-end space-y-2"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,19 @@
import { LayerObjectType } from '../../../modules/house-planner/types';
export interface SelectOptions {
value: string | null | undefined;
title: string;
}
export interface ObjectProperty {
key: string;
title: string;
type: 'string' | 'boolean' | 'color' | 'number' | 'select';
groupable?: boolean;
options?: SelectOptions[];
}
export interface ObjectProperties {
type: LayerObjectType;
properties: ObjectProperty[];
}

View File

@ -1,5 +1,12 @@
import { HousePlannerCanvasGrid } from './grid'; import { HousePlannerCanvasGrid } from './grid';
import { BezierSegment, Layer, Line, LineSegment, Vec2 } from './interfaces'; import {
BezierSegment,
Layer,
LayerObject,
Line,
LineSegment,
Vec2,
} from './interfaces';
import { HousePlannerCanvasTools } from './tools'; import { HousePlannerCanvasTools } from './tools';
import { import {
rad2deg, rad2deg,
@ -139,6 +146,67 @@ export class HousePlannerCanvas {
return this.ctx.isPointInStroke(fakePath, x, y); return this.ctx.isPointInStroke(fakePath, x, y);
} }
getLayerById(layerId: number) {
return this.layers.find((layer) => layer.id === layerId);
}
getLayerObjectById(layerId: number, objectId: number) {
const findLayer = this.getLayerById(layerId);
if (!findLayer) return undefined;
const findObject = findLayer.contents.find(
(content) => content.id === objectId
);
if (!findObject) return undefined;
return findObject;
}
updateObjectProperties(
layerId: number,
objectId: number,
properties: Partial<Omit<LayerObject | Line, 'segments'>>
) {
const object = this.getLayerObjectById(layerId, objectId);
if (!object) return;
this.tools.history.appendToHistory(
Object.keys(properties).map((key) => ({
object,
property: key,
value: object[key as keyof typeof object],
}))
);
Object.assign(object, properties);
this.canvas.dispatchEvent(
new CustomEvent('hpc:update', {
detail: { event: 'properties-object', object },
})
);
this.draw();
return object;
}
updateLayerProperties(
layerId: number,
properties: Partial<Omit<Layer, 'contents'>>
) {
const layer = this.getLayerById(layerId);
if (!layer) return;
this.tools.history.appendToHistory(
Object.keys(properties).map((key) => ({
object: layer,
property: key,
value: layer[key as keyof typeof layer],
}))
);
Object.assign(layer, properties);
this.canvas.dispatchEvent(
new CustomEvent('hpc:update', {
detail: { event: 'properties-layer', layer },
})
);
this.draw();
return layer;
}
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;
e.preventDefault(); e.preventDefault();

View File

@ -1,4 +1,4 @@
import { SubToolType, ToolType } from './types'; import { LayerObjectType, SubToolType, ToolType } from './types';
export type Vec2 = [number, number]; export type Vec2 = [number, number];
export interface LineSegment { export interface LineSegment {
@ -12,10 +12,11 @@ export interface BezierSegment extends LineSegment {
} }
export interface LayerObject { export interface LayerObject {
id: number;
name: string; name: string;
visible: boolean; visible: boolean;
selected: boolean; selected: boolean;
type: 'line' | 'room' | 'curve' | 'object'; type: LayerObjectType;
} }
export interface Line extends LayerObject { export interface Line extends LayerObject {
@ -29,7 +30,7 @@ export interface Line extends LayerObject {
} }
export interface Layer { export interface Layer {
index: number; id: number;
contents: LayerObject[]; contents: LayerObject[];
name: string; name: string;
color: string; color: string;

View File

@ -13,6 +13,7 @@ import {
import { ToolType, SubToolType, BezierControl, LineControl } from './types'; import { ToolType, SubToolType, BezierControl, LineControl } from './types';
import { import {
vec2Add, vec2Add,
vec2Distance,
vec2Equals, vec2Equals,
vec2InCircle, vec2InCircle,
vec2Inverse, vec2Inverse,
@ -24,6 +25,7 @@ export class HousePlannerCanvasTools {
public selectedLayer?: Layer; public selectedLayer?: Layer;
public selectedObjects: LayerObject[] = []; public selectedObjects: LayerObject[] = [];
public mousePosition: Vec2 = [0, 0]; public mousePosition: Vec2 = [0, 0];
public mouseClickPosition: Vec2 = [0, 0];
public mousePositionSnapped: Vec2 = [0, 0]; public mousePositionSnapped: Vec2 = [0, 0];
public gridSnap = true; public gridSnap = true;
public gridSnapScale = 8; public gridSnapScale = 8;
@ -70,6 +72,18 @@ export class HousePlannerCanvasTools {
} }
this.selectedLayer = layer; this.selectedLayer = layer;
this.selectedLayer.active = true; this.selectedLayer.active = true;
this.canvas.dispatchEvent(
new CustomEvent('hpc:layerchange', {
detail: this.selectedObjects,
})
);
this.canvas.dispatchEvent(
new CustomEvent('hpc:update', {
detail: {
event: 'layerchange',
},
})
);
} }
selectObject(object?: LayerObject | null, add = false) { selectObject(object?: LayerObject | null, add = false) {
@ -81,6 +95,7 @@ export class HousePlannerCanvasTools {
this.selectedObjects.length = 0; this.selectedObjects.length = 0;
} }
this.manager.draw(); this.manager.draw();
this.selectedEvent();
return; return;
} }
@ -91,6 +106,7 @@ export class HousePlannerCanvasTools {
object.selected = false; object.selected = false;
this.selectedObjects.splice(foundAt, 1); this.selectedObjects.splice(foundAt, 1);
this.manager.draw(); this.manager.draw();
this.selectedEvent();
} }
return; return;
} }
@ -100,6 +116,7 @@ export class HousePlannerCanvasTools {
object.selected = true; object.selected = true;
this.selectedObjects.push(object); this.selectedObjects.push(object);
this.manager.draw(); this.manager.draw();
this.selectedEvent();
return; return;
} }
@ -111,6 +128,7 @@ export class HousePlannerCanvasTools {
this.selectedObjects = [object]; this.selectedObjects = [object];
this.manager.draw(); this.manager.draw();
this.selectedEvent();
} }
getMousedObject() { getMousedObject() {
@ -258,6 +276,7 @@ export class HousePlannerCanvasTools {
} }
onMouseDown(e: MouseEvent) { onMouseDown(e: MouseEvent) {
this.mousePosition = [e.clientX, e.clientY]; this.mousePosition = [e.clientX, e.clientY];
this.mouseClickPosition = [...this.mousePosition];
this.mousePositionSnapped = this.gridSnap this.mousePositionSnapped = this.gridSnap
? vec2Snap(this.mousePosition, this.gridSnapScale) ? vec2Snap(this.mousePosition, this.gridSnapScale)
: this.mousePosition; : this.mousePosition;
@ -302,6 +321,7 @@ export class HousePlannerCanvasTools {
e.preventDefault(); e.preventDefault();
const touch = e.touches[0] || e.changedTouches[0]; const touch = e.touches[0] || e.changedTouches[0];
this.mousePosition = [touch.pageX, touch.pageY]; this.mousePosition = [touch.pageX, touch.pageY];
this.mouseClickPosition = [...this.mousePosition];
this.mousePositionSnapped = this.gridSnap this.mousePositionSnapped = this.gridSnap
? vec2Snap(this.mousePosition, this.gridSnapScale) ? vec2Snap(this.mousePosition, this.gridSnapScale)
: this.mousePosition; : this.mousePosition;
@ -435,6 +455,12 @@ export class HousePlannerCanvasTools {
} }
this.drawingLine = null; this.drawingLine = null;
this.manager.draw(); this.manager.draw();
this.canvas.dispatchEvent(
new CustomEvent('hpc:update', {
detail: { event: 'draw-cancel' },
})
);
} }
} }
@ -543,6 +569,14 @@ export class HousePlannerCanvasTools {
} }
private pointerUp() { private pointerUp() {
// FIXME: possibly there's a better approach, but right now some clicks do not register
if (
this.moved &&
vec2Distance(this.mouseClickPosition, this.mousePosition) < 0.25
) {
this.moved = false;
}
if (!this.moved && !this.handlingBezier && !this.handlingLine) { if (!this.moved && !this.handlingBezier && !this.handlingLine) {
if (this.tool === 'line') { if (this.tool === 'line') {
this.startLine(); this.startLine();
@ -588,6 +622,19 @@ export class HousePlannerCanvasTools {
this.movingObject = false; this.movingObject = false;
} }
private getSequentialId() {
return (
this.manager.layers.reduce(
(total, current) =>
current.contents.reduce(
(total2, current2) => current2.id + total2,
0
) + total,
0
) + 1
);
}
private startLine() { private startLine() {
if (!this.selectedLayer?.visible) return; if (!this.selectedLayer?.visible) return;
if (this.drawingLine) { if (this.drawingLine) {
@ -640,6 +687,7 @@ export class HousePlannerCanvasTools {
} }
const newLineObject: Line = { const newLineObject: Line = {
id: this.getSequentialId(),
name: 'New Line', name: 'New Line',
type: this.subTool!, type: this.subTool!,
visible: true, visible: true,

View File

@ -9,3 +9,4 @@ export type BezierControl = [
Vec2 Vec2
]; ];
export type LineControl = [LineSegment, 'start' | 'end', Vec2]; export type LineControl = [LineSegment, 'start' | 'end', Vec2];
export type LayerObjectType = 'line' | 'room' | 'curve' | 'object';

View File

@ -54,3 +54,6 @@ export const vec2PointFromAngle = (
export const deg2rad = (deg: number) => deg * (Math.PI / 180); export const deg2rad = (deg: number) => deg * (Math.PI / 180);
export const rad2deg = (rad: number) => rad * (180 / Math.PI); export const rad2deg = (rad: number) => rad * (180 / Math.PI);
export const randomNumber = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1) + min);

6
src/utils/deep-unref.ts Normal file
View File

@ -0,0 +1,6 @@
import { MaybeRef } from '@vueuse/core';
import { isRef } from 'vue';
export default function deepUnref<T>(input: MaybeRef<T>) {
return JSON.parse(JSON.stringify(isRef(input) ? input.value : input)) as T;
}