shortcuts, clipboard, history
This commit is contained in:
parent
4e7bdd7fc0
commit
ce58cfaa01
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue