Skip to content

Commit

Permalink
Engine (#271)
Browse files Browse the repository at this point in the history
* Engine separation.

* Better hit test

* Fix tests

* Improve engine design.

* More tests

* Factories tests

* More tests
  • Loading branch information
SebastianStehle authored Nov 6, 2024
1 parent fb048ee commit e095ac5
Show file tree
Hide file tree
Showing 61 changed files with 2,114 additions and 1,317 deletions.
4 changes: 2 additions & 2 deletions src/core/react/Grid.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
* Copyright (c) Sebastian Stehle. All rights reserved.
*/

import { ComponentMeta } from '@storybook/react';
import { Meta } from '@storybook/react';
import { Grid } from './Grid';

export default {
component: Grid,
} as ComponentMeta<typeof Grid>;
} as Meta<typeof Grid>;

const items: number[] = [];

Expand Down
2 changes: 1 addition & 1 deletion src/core/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export * from './react';
export * from './record';
export * from './rect2';
export * from './rotation';
export * from './svg-helper';
export * from './subscription';
export * from './text-measurer';
export * from './timer';
export * from './types';
export * from './vec2';
38 changes: 38 additions & 0 deletions src/core/utils/text-measurer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* mydraft.cc
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved.
*/

import { sizeInPx } from './react';

export interface TextMeasurer {
getTextWidth(text: string, fontSize: number, fontFamily: string): number;
}

class DefaultTextMeasurer {
private readonly measureDiv: HTMLDivElement;

constructor() {
this.measureDiv = document.createElement('div');
this.measureDiv.style.height = 'auto';
this.measureDiv.style.position = 'absolute';
this.measureDiv.style.visibility = 'hidden';
this.measureDiv.style.width = 'auto';
this.measureDiv.style.whiteSpace = 'nowrap';
document.body.appendChild(this.measureDiv);
}

public getTextWidth(text: string, fontSize: number, fontFamily: string) {
this.measureDiv.textContent = text;
this.measureDiv.style.fontSize = sizeInPx(fontSize);
this.measureDiv.style.fontFamily = fontFamily;

return this.measureDiv.clientWidth + 1;
}
}

export module TextMeasurer {
export const DEFAULT = new DefaultTextMeasurer();
}
2 changes: 1 addition & 1 deletion src/wireframes/components/CustomDragLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { useDragLayer, XYCoord } from 'react-dnd';
import { ShapePlugin } from '../interface';
import { ShapePlugin } from '@app/wireframes/interface';
import { getViewBox, ShapeRenderer } from '../shapes/ShapeRenderer';
import './CustomDragLayer.scss';

Expand Down
6 changes: 3 additions & 3 deletions src/wireframes/components/EditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import { NativeTypes } from 'react-dnd-html5-backend';
import { findDOMNode } from 'react-dom';
import { Canvas, loadImagesToClipboardItems, useClipboard, useEventCallback, ViewBox } from '@app/core';
import { useAppDispatch } from '@app/store';
import { addShape, changeItemsAppearance, Diagram, getDiagram, getDiagramId, getEditor, getMasterDiagram, getSelection, RendererService, selectItems, Transform, transformItems, useStore } from '@app/wireframes/model';
import { ShapeSource } from '@app/wireframes/interface';
import { addShape, changeItemsAppearance, Diagram, getDiagram, getDiagramId, getEditor, getMasterDiagram, getSelection, PluginRegistry, selectItems, Transform, transformItems, useStore } from '@app/wireframes/model';
import { Editor } from '@app/wireframes/renderer/Editor';
import { DiagramRef, ItemsRef } from '../model/actions/utils';
import { ShapeSource } from './../interface';
import { useContextMenu } from './context-menu';
import './EditorView.scss';

Expand Down Expand Up @@ -70,7 +70,7 @@ export const EditorViewInner = ({ diagram, viewBox }: { diagram: Diagram; viewBo
return;
}

const shapes = RendererService.createShapes(sources);
const shapes = PluginRegistry.createShapes(sources);

for (const { appearance, renderer, size } of shapes) {
dispatch(addShape(selectedDiagramId, renderer, { position: { x, y }, size, appearance }));
Expand Down
2 changes: 1 addition & 1 deletion src/wireframes/components/actions/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function useAppearanceCore<T>(selectedDiagramId: RefDiagramId, selectionS

const doChangeAppearance = useEventCallback((value: T) => {
if (selectedDiagramId && selectionSet) {
dispatch(changeItemsAppearance(selectedDiagramId, selectionSet.deepEditableItems, key, converter.write(value), force));
dispatch(changeItemsAppearance(selectedDiagramId, selectionSet.editableItems, key, converter.write(value), force));
}
});

Expand Down
4 changes: 2 additions & 2 deletions src/wireframes/components/assets/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useStore as useReduxStore } from 'react-redux';
import { Grid, useEventCallback } from '@app/core';
import { RootState, useAppDispatch } from '@app/store';
import { texts } from '@app/texts';
import { addShape, filterIcons, getDiagramId, getFilteredIcons, getIconSet, getIconSets, getIconsFilter, IconInfo, RendererService, selectIcons, useStore } from '@app/wireframes/model';
import { addShape, filterIcons, getDiagramId, getFilteredIcons, getIconSet, getIconSets, getIconsFilter, IconInfo, PluginRegistry, selectIcons, useStore } from '@app/wireframes/model';
import { Icon } from './Icon';
import './Icons.scss';

Expand All @@ -33,7 +33,7 @@ export const Icons = React.memo(() => {
const selectedDiagramId = getDiagramId(store.getState() as any);

if (selectedDiagramId) {
const shapes = RendererService.createShapes([{ type: 'Icon', ...icon }]);
const shapes = PluginRegistry.createShapes([{ type: 'Icon', ...icon }]);

for (const { size, appearance, renderer } of shapes) {
dispatch(addShape(selectedDiagramId, renderer, { position: { x: 100, y: 100 }, size, appearance }));
Expand Down
8 changes: 8 additions & 0 deletions src/wireframes/engine/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* mydraft.cc
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved.
*/

export * from './interface';
154 changes: 154 additions & 0 deletions src/wireframes/engine/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* mydraft.cc
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved.
*/

import { Vec2 } from '@app/core';
import { ShapePlugin } from '@app/wireframes/interface';
import { DiagramItem } from './../model';

export type NextListener<T> = (event: T) => void;

export class HitEvent {
constructor(
public readonly event: MouseEvent,
public readonly position: Vec2,
public readonly layer: EngineLayer,
public readonly object?: EngineObject | null,
public readonly item?: DiagramItem | null,
) {
}
}

export interface Engine {
// Add a new layer to the render output with the name of the layer for debugging.
layer(id?: string): EngineLayer;

// Sets the layer that is used for click events.
setClickLayer(layer: EngineLayer): void;

// Subscribe to all events.
subscribe(listener: Listener): void;

// Unsubscribe from all events.
unsubscribe(listener: Listener): void;
}

export interface Listener {
onBlur?(event: FocusEvent, next: NextListener<FocusEvent>): void;
onDoubleClick?(event: HitEvent, next: NextListener<HitEvent>): void;
onClick?(event: HitEvent, next: NextListener<HitEvent>): boolean;
onMouseDown?(event: HitEvent, next: NextListener<HitEvent>): void;
onMouseDrag?(event: HitEvent, next: NextListener<HitEvent>): void;
onMouseMove?(event: HitEvent, next: NextListener<HitEvent>): void;
onMouseUp?(event: HitEvent, next: NextListener<HitEvent>): void;
onKeyDown?(event: KeyboardEvent, next: (event: KeyboardEvent) => void): void;
onKeyUp?(event: KeyboardEvent, next: (event: KeyboardEvent) => void): void;
}

export interface EngineLayer {
// Creates a new object to render a rect.
rect(): EngineRect;

// Creates a new object to render a ellipse.
ellipse(): EngineRect;

// Creates a new object to render a line.
line(): EngineLine;

// Creates a new object to render a text element.
text(): EngineText;

// Creates a new object to render an item.
item(plugin: ShapePlugin): EngineItem;

// Removes the layer from the parent.
remove(): void;

// Shows the layer.
show(): void;

// Hides the layer.
hide(): void;

// Makes a hit and returns matching elements.
hitTest(x: number, y: number): EngineObject[];
}

export interface EngineRectOrEllipse extends EngineObject {
// Set the stroke width of the object.
strokeWidth(width: number): void;

// Set the stroke color of the object.
strokeColor(color: string): void;

// Sets the fill color.
fill(value: string): void;

// Renders with position, size and rotation.
plot(args: { x: number; y: number; w: number; h: number; rotation?: number; rx?: number; ry?: number }): void;
}

export interface EngineRect extends EngineRectOrEllipse {
}

export interface EngineEllipse extends EngineRectOrEllipse {
}

export interface EngineLine extends EngineObject {
// The color of the line.
color(value: string): void;

// Renders the line from (x1, y1) to (x2, y2).
plot(args: { x1: number; y1: number; x2: number; y2: number; width: number }): void;
}

export interface EngineText extends EngineObject {
// Sets the text color.
color(value: string): void;

// Sets the background color.
fill(value: string): void;

// Sets the font size.
fontSize(value: string): void;

// Sets the font family.
fontFamily(value: string): void;

// Sets the text content.
text(value: string): void;

// Defines the dimensions.
plot(args: { x: number; y: number; w: number; h: number; padding: number }): void;
}

export interface EngineObject {
// Defines the cursor for the object.
cursor(value: string | number): void;

// Removes the element from the parent.
remove(): void;

// Shows the object.
show(): void;

// Hides the object.
hide(): void;

// Disable the object.
disable(): void;

// Sets or gets the label.
label(value?: string): string;
}

export interface EngineItem extends EngineObject {
// Removes the element from the parent.
detach(): void;

// Renders the item.
plot(item: DiagramItem | null): void;
}
63 changes: 63 additions & 0 deletions src/wireframes/engine/svg/canvas/SvgCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* mydraft.cc
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved.
*/

import * as svg from '@svgdotjs/svg.js';
import * as React from 'react';
import { Vec2, ViewBox } from '@app/core';
import { SvgEngine } from '../engine';

export interface SvgCanvasProps {
// The optional viewbox.
viewBox: ViewBox;

// The size.
size?: Vec2;

// The class name.
className?: string;

// The callback when the canvas has been initialized.
onInit: (engine: SvgEngine) => any;
}

export const SvgCanvasView = (props: SvgCanvasProps) => {
const {
className,
onInit,
viewBox,
} = props;

const [engine, setEngine] = React.useState<SvgEngine>();

const doInit = React.useCallback((ref: HTMLDivElement) => {
if (!ref) {
return;
}

const doc = svg.SVG().addTo(ref).css({ position: 'relative', overflow: 'visible' }).attr('tabindex', 0);

setEngine(new SvgEngine(doc));
}, []);

React.useEffect(() => {
if (engine && onInit) {
onInit(engine);
}
}, [engine, onInit]);

React.useEffect(() => {
if (!engine) {
return;
}

engine.viewBox(viewBox.minX, viewBox.minY, viewBox.maxX, viewBox.maxY);
}, [engine, viewBox.minX, viewBox.minY, viewBox.maxX, viewBox.maxY]);

return (
<div className={className} ref={doInit} />
);
};
Loading

0 comments on commit e095ac5

Please sign in to comment.