shortcuts, clipboard, history

This commit is contained in:
Evert Prants 2023-06-04 18:25:00 +03:00
parent 4e7bdd7fc0
commit ce58cfaa01
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
13 changed files with 418 additions and 4 deletions

View File

@ -82,8 +82,7 @@ register('initialized', () => createSceneMap());
register('selected', (event) => updateSelectionMap(event));
register('deselected', (event) => updateSelectionMap(event));
register('change', (event) => {
console.log(event);
if (['name', 'visible'].includes(event.property)) createSceneMap();
if (['name', 'visible', 'parent'].includes(event.property)) createSceneMap();
rerenderSelection();
});

View File

@ -9,6 +9,7 @@
.sidebar-panel {
display: flex;
flex-direction: column;
overflow: hidden;
&-title {
background-color: #e5e5e5;
@ -17,5 +18,9 @@
font-weight: bold;
color: #5a5a5a;
}
&-inner {
overflow: auto;
}
}
</style>

View File

@ -9,6 +9,8 @@ import {
} from '@freeblox/engine';
import { EditorEvents } from '../types/events';
import { WorkspaceComponent } from './workspace';
import { HistoryComponent } from './history';
import { ShotcutsComponent } from './shortcuts';
export class Editor extends GameRunner {
public lastTick = performance.now();
@ -17,6 +19,8 @@ export class Editor extends GameRunner {
public element!: HTMLElement;
public viewport!: ViewportComponent;
public workspace!: WorkspaceComponent;
public history!: HistoryComponent;
public shortcuts!: ShotcutsComponent;
public mouse!: MouseComponent;
public environment!: EnvironmentComponent;
public level!: LevelComponent;
@ -32,6 +36,12 @@ export class Editor extends GameRunner {
this.workspace = new WorkspaceComponent(this.render, this.events);
this.workspace.initialize();
this.history = new HistoryComponent(this.render, this.events);
this.history.initialize();
this.shortcuts = new ShotcutsComponent(this.render, this.events);
this.shortcuts.initialize();
this.mouse = new MouseComponent(this.render, this.events);
this.mouse.initialize();
@ -70,6 +80,8 @@ export class Editor extends GameRunner {
this.workspace.cleanUp();
this.mouse.cleanUp();
this.render.cleanUp();
this.history.cleanUp();
this.shortcuts.cleanUp();
}
public getSceneTree() {

View File

@ -0,0 +1,197 @@
import {
ChangeEvent,
EngineComponent,
EventEmitter,
RemoveEvent,
Renderer,
} from '@freeblox/engine';
import { EditorEvents, TransformEvent } from '../types/events';
import { Euler, Object3D, Vector3 } from 'three';
type Changes = Record<string, unknown>;
type HistoryType = [Object, Changes, number, number?];
export class HistoryComponent extends EngineComponent {
public cleanUpEvents?: Function;
private history: HistoryType[] = [];
private restory: HistoryType[] = [];
constructor(
protected renderer: Renderer,
protected events: EventEmitter<EditorEvents>
) {
super(renderer, events);
}
initialize(): void {
this.cleanUpEvents = this.bindEvents();
}
update(delta: number): void {}
cleanUp(): void {
this.cleanUpEvents?.call(this);
this.history.length = 0;
this.restory.length = 0;
}
public add(object: Object, changes: Changes, union?: number) {
this.history.push([object, changes, Date.now(), union]);
}
public undo() {
const action = this.history.pop();
if (!action) return;
const prevState: Changes = {};
for (const prop in action[1]) {
const value = action[1][prop];
const object = action[0] as any;
if (!object) break;
prevState[prop] = object[prop];
if (object[prop]?.clone) prevState[prop] = object[prop].clone();
if (prop === 'parent') {
object?.removeFromParent();
(value as Object3D)?.add(object);
} else if (object[prop].copy && prop !== 'material') {
object[prop].copy(value);
} else {
object[prop] = value;
}
this.events.emit('change', {
applied: true,
edited: false,
object,
property: prop,
value: value,
});
}
this.restory.push([action[0], prevState, Date.now(), action[3]]);
// Multi-undo support
if (
!!action[3] &&
this.history[this.history.length - 1]?.[3] === action[3]
) {
this.undo();
}
}
public redo() {
const action = this.restory.pop();
if (!action) return;
const prevState: Changes = {};
for (const prop in action[1]) {
const value = action[1][prop];
const object = action[0] as any;
if (!object) break;
prevState[prop] = object[prop];
if (object[prop]?.clone) prevState[prop] = object[prop].clone();
if (prop === 'parent') {
object?.removeFromParent();
(value as Object3D)?.add(object);
} else if (object[prop].copy && prop !== 'material') {
object[prop].copy(value);
} else {
object[prop] = value;
}
this.events.emit('change', {
applied: true,
edited: false,
object,
property: prop,
value: value,
});
}
this.history.push([action[0], prevState, Date.now(), action[3]]);
// Multi-redo support
if (
!!action[3] &&
this.restory[this.restory.length - 1]?.[3] === action[3]
) {
this.redo();
}
}
private bindEvents() {
let transformed = false;
let transforming = false;
let transformPosition: Vector3;
let transformRotation: Euler;
let transformScale: Vector3;
const transformStart = (event: TransformEvent) => {
transformPosition = event.position.clone();
transformRotation = event.rotation.clone();
transformScale = event.scale.clone();
transforming = true;
transformed = false;
};
const transformEnd = (event: TransformEvent) => {
transforming = false;
if (transformed) {
this.add(event.object, {
position: transformPosition,
rotation: transformRotation,
scale: transformScale,
});
}
};
const changeEvent = (event: ChangeEvent) => {
if (transforming && event.applied) {
transformed = true;
}
if (event.applied) return;
let value = (event.object as any)[event.property];
if (value?.clone) value = value.clone();
this.add(event.object, {
[event.property]: value,
});
};
const removeEvent = (event: RemoveEvent) => {
if (event.applied) return;
if (Array.isArray(event.object)) {
const union = Date.now();
event.object.forEach((object) =>
this.add(object, { parent: object.parent }, union)
);
return;
}
this.add(event.object, { parent: event.object.parent });
};
const undo = this.undo.bind(this);
const redo = this.redo.bind(this);
this.events.addListener('change', changeEvent);
this.events.addListener('transformStart', transformStart);
this.events.addListener('transformEnd', transformEnd);
this.events.addListener('reparent', removeEvent);
this.events.addListener('remove', removeEvent);
this.events.addListener('undo', undo);
this.events.addListener('redo', redo);
return () => {
this.events.removeEventListener('change', changeEvent);
this.events.removeEventListener('transformStart', transformStart);
this.events.removeEventListener('transformEnd', transformEnd);
this.events.removeEventListener('reparent', removeEvent);
this.events.removeEventListener('remove', removeEvent);
this.events.removeEventListener('undo', undo);
this.events.removeEventListener('redo', redo);
};
}
}

View File

@ -0,0 +1,60 @@
import { EngineComponent, EventEmitter, Renderer } from '@freeblox/engine';
import { EditorEvents } from '..';
export class ShotcutsComponent extends EngineComponent {
public cleanUpEvents?: Function;
constructor(
protected renderer: Renderer,
protected events: EventEmitter<EditorEvents>
) {
super(renderer, events);
}
initialize(): void {
this.cleanUpEvents = this.bindEvents();
}
update(delta: number): void {}
cleanUp(): void {
this.cleanUpEvents?.call(this);
}
private bindEvents() {
const keyPressEvent = (event: KeyboardEvent) => {
if (!event.target || (event.target as HTMLElement).tagName !== 'BODY')
return;
if (event.key === 'Delete') {
this.events.emit('delete');
return;
}
if (!event.ctrlKey) return;
switch (event.key) {
case 'c':
this.events.emit('copy');
break;
case 'v':
this.events.emit('paste', undefined);
break;
case 'x':
this.events.emit('cut');
break;
case 'z':
this.events.emit('undo');
break;
case 'y':
this.events.emit('redo');
break;
}
};
window.addEventListener('keydown', keyPressEvent);
return () => {
window.removeEventListener('keydown', keyPressEvent);
};
}
}

View File

@ -19,6 +19,7 @@ import {
Euler,
GridHelper,
Material,
MathUtils,
Object3D,
Vector3,
} from 'three';
@ -49,6 +50,8 @@ export class WorkspaceComponent extends EngineComponent {
public transforming = false;
public selection: Object3D[] = [];
public cutOperation = false;
public clipboard: Object3D[] = [];
public cleanUpEvents?: Function;
constructor(
@ -264,6 +267,58 @@ export class WorkspaceComponent extends EngineComponent {
}
};
const copyEvent = () => {
if (!this.selection.length) return;
this.clipboard = [];
this.selection.forEach((entry) => {
this.clipboard.push(entry);
});
};
const pasteEvent = (target?: Object3D) => {
if (!this.clipboard.length) return;
this.selection.length = 0;
this.clipboard.forEach((entry) => {
const newObject = this.cutOperation ? entry : entry.clone();
(target || this.world).add(newObject);
this.selection.push(newObject);
});
this.selection.forEach((entry) =>
this.events.emit('selected', {
object: entry,
selection: this.selection,
multi: this.selection.length > 1,
})
);
this.cutOperation = false;
};
const deleteEvent = () => {
if (!this.selection.length) return;
const toDelete = [...this.selection];
this.selection.forEach((entry) =>
this.events.emit('deselected', {
object: entry,
selection: [],
})
);
this.events.emit('remove', {
object: toDelete,
});
this.selection.length = 0;
};
const cutEvent = () => {
if (!this.selection.length) return;
this.clipboard = [];
this.selection.forEach((entry) => {
this.clipboard.push(entry);
});
this.cutOperation = true;
deleteEvent();
};
this.events.addListener('mouseDown', mouseDownEventHandler);
this.events.addListener('mouseMove', mouseMoveEventHandler);
this.events.addListener('mouseUp', mouseUpEventHandler);
@ -274,6 +329,10 @@ export class WorkspaceComponent extends EngineComponent {
this.events.addListener('transformSnap', transformSnap);
this.events.addListener('transformRotationSnap', transformRotationSnap);
this.events.addListener('change', changeListener);
this.events.addListener('cut', cutEvent);
this.events.addListener('copy', copyEvent);
this.events.addListener('paste', pasteEvent);
this.events.addListener('delete', deleteEvent);
return () => {
this.events.removeEventListener('mouseDown', mouseDownEventHandler);
@ -289,6 +348,10 @@ export class WorkspaceComponent extends EngineComponent {
transformRotationSnap
);
this.events.removeEventListener('change', changeListener);
this.events.removeEventListener('cut', cutEvent);
this.events.removeEventListener('copy', copyEvent);
this.events.removeEventListener('paste', pasteEvent);
this.events.removeEventListener('delete', deleteEvent);
};
}
@ -309,7 +372,7 @@ export class WorkspaceComponent extends EngineComponent {
private initializeControls() {
let translationSnap: number | null = 0.5;
let scaleSnap: number | null = 0.5;
let rotationSnap: number | null = null;
let rotationSnap: number | null = MathUtils.degToRad(15);
let mode: 'translate' | 'rotate' | 'scale' = 'translate';
if (this.orbitControls) this.orbitControls.dispose();

View File

@ -38,6 +38,12 @@ export type Events = {
select: (event: SelectEvent) => void;
selected: (event: SelectionEvent) => void;
deselected: (event: SelectionEvent) => void;
undo: () => void;
redo: () => void;
cut: () => void;
copy: () => void;
delete: () => void;
paste: (event: Object3D | undefined) => void;
};
export type EditorEvents = Events & EngineEvents;

View File

@ -5,6 +5,10 @@ import { Renderer } from '../core/renderer';
import { EventEmitter } from '../utils/events';
import { Environment } from '../gameobjects/environment.object';
/**
* This component manages game environment and world lighting
* @listens setEnvironment
*/
export class EnvironmentComponent extends EngineComponent {
public ambient!: AmbientLight;
public directional!: DirectionalLight;

View File

@ -1,12 +1,23 @@
import { Renderer } from '../core/renderer';
import { EngineComponent } from '../types/engine-component';
import { ChangeEvent, EngineEvents } from '../types/events';
import {
ChangeEvent,
EngineEvents,
RemoveEvent,
ReparentEvent,
} from '../types/events';
import { EventEmitter } from '../utils/events';
import { Environment } from '../gameobjects/environment.object';
import { assetManager } from '../assets/manager';
import { WorldFile } from '../types/world-file';
import { World } from '../gameobjects/world.object';
/**
* Game level management component
* @listens change Applies changes to objects
* @listens remove Removes objects from scene
* @listens reparent Reparents object
*/
export class LevelComponent extends EngineComponent {
private world!: World;
private environment!: Environment;
@ -60,10 +71,38 @@ export class LevelComponent extends EngineComponent {
else (event.object as any)[event.property] = event.value;
};
const removeEvent = (event: RemoveEvent) => {
if (event.applied || !event.object) return;
if (Array.isArray(event.object)) {
event.object.forEach((object) => object.removeFromParent());
return;
}
event.object.removeFromParent();
};
const reparentEvent = (event: ReparentEvent) => {
if (event.applied || !event.object || !event.parent) return;
if (Array.isArray(event.object)) {
event.object.forEach((object) => {
object.removeFromParent();
event.parent.add(object);
});
return;
}
event.object.removeFromParent();
event.parent.add(event.object);
};
this.events.addListener('change', changeEvent);
this.events.addListener('remove', removeEvent);
this.events.addListener('reparent', reparentEvent);
return () => {
this.events.removeEventListener('change', changeEvent);
this.events.removeEventListener('remove', removeEvent);
this.events.removeEventListener('reparent', reparentEvent);
};
}
}

View File

@ -7,6 +7,9 @@ import { World } from '../gameobjects/world.object';
type MouseMap = [boolean, boolean, boolean];
/**
* Manage mouse and object picking from screen.
*/
export class MouseComponent extends EngineComponent {
private world!: World;

View File

@ -4,6 +4,9 @@ import { EngineComponent } from '../types/engine-component';
import { Renderer } from '../core/renderer';
import { EventEmitter } from '../utils/events';
/**
* Manage viewport sizing
*/
export class ViewportComponent extends EngineComponent {
private cleanUpEvents?: Function;

View File

@ -49,6 +49,17 @@ export interface SceneTreeEvent {
environment: EnvironmentEvent;
}
export interface RemoveEvent {
object: Object3D | Object3D[];
applied?: boolean;
}
export interface ReparentEvent {
object: Object3D | Object3D[];
parent: Object3D;
applied?: boolean;
}
export type EngineEvents = {
error: (error: Error) => void;
mouseDown: (event: MouseButtonEvent) => void;
@ -57,5 +68,7 @@ export type EngineEvents = {
setEnvironment: (event: EnvironmentEvent) => void;
change: (event: ChangeEvent) => void;
resize: (event: Vector2) => void;
remove: (event: RemoveEvent) => void;
reparent: (event: ReparentEvent) => void;
initialized: () => void;
};

View File

@ -54,6 +54,16 @@ export class GameObject extends Object3D {
return object;
}
override copy(object: Object3D, recursive = true) {
super.copy(object as any, recursive);
Object.keys(this.editorProperties)
.filter((key) => !['position', 'rotation', 'scale'].includes(key))
.forEach((key) => {
(this as any)[key] = (object as any)[key];
});
return this;
}
parse(input: SerializedObject) {
Object.keys(input)
.filter((key) => key !== 'children')