feat: improve layouts

This commit is contained in:
aarthificial
2022-05-16 22:07:03 +02:00
parent 178db3d95c
commit 9a1fb5c7cd
27 changed files with 467 additions and 611 deletions

View File

@@ -11,7 +11,7 @@ import {Node} from 'konva/lib/Node';
import {Group} from 'konva/lib/Group';
import {Shape} from 'konva/lib/Shape';
import {SceneTransition} from './transitions';
import {decorate, threadable} from './decorators';
import {decorate, KonvaNode, threadable} from './decorators';
import {PROJECT, SCENE} from './symbols';
import {SimpleEventDispatcher} from 'strongly-typed-events';
import {setScene} from './utils';
@@ -34,6 +34,7 @@ export interface TimeEvent {
offset: number;
}
@KonvaNode()
export class Scene extends Layer {
public threadsCallback: ThreadsCallback = null;
public firstFrame: number = 0;
@@ -133,6 +134,7 @@ export class Scene extends Layer {
public async next() {
setScene(this);
let result = this.runner.next();
this.updateLayout();
while (result.value) {
if (isPromise(result.value)) {
const value = await result.value;
@@ -145,6 +147,7 @@ export class Scene extends Layer {
console.log('Invalid value: ', result.value);
result = this.runner.next();
}
this.updateLayout();
}
if (result.done) {
@@ -152,6 +155,22 @@ export class Scene extends Layer {
}
}
public updateLayout(): boolean {
super.updateLayout();
const result = this.wasDirty();
let limit = 10;
while (this.wasDirty() && limit > 0) {
super.updateLayout();
limit--;
}
if (limit === 0) {
console.warn('Layout iteration limit exceeded');
}
return result;
}
@threadable()
public *transition(transitionRunner?: SceneTransition) {
if (transitionRunner) {
@@ -196,6 +215,7 @@ export class Scene extends Layer {
public add(...children: (Shape | Group)[]): this {
super.add(...children.flat());
this.debugNode.moveToTop();
this.updateLayout();
return this;
}

View File

@@ -1,3 +1,4 @@
import {Node} from 'konva/lib/Node';
import {Shape, ShapeConfig} from 'konva/lib/Shape';
import {_registerNode} from 'konva/lib/Global';
import {Context} from 'konva/lib/Context';
@@ -8,6 +9,7 @@ import {
getNumberValidator,
} from 'konva/lib/Validators';
import {clamp} from 'three/src/math/MathUtils';
import {getset, KonvaNode} from '../decorators';
export interface ArrowConfig extends ShapeConfig {
radius?: number;
@@ -164,18 +166,23 @@ class CircleSegment extends Segment {
}
}
@KonvaNode()
export class Arrow extends Shape<ArrowConfig> {
protected dirty = true;
@getset(8, Node.prototype.markDirty)
public radius: GetSet<number, this>;
@getset([], Node.prototype.markDirty)
public points: GetSet<number[], this>;
@getset(0)
public start: GetSet<number, this>;
@getset(1)
public end: GetSet<number, this>;
@getset(16)
public arrowSize: GetSet<number, this>;
private segments: Segment[] = [];
private arcLength: number = 0;
_sceneFunc(context: Context) {
if (this.dirty) {
this.calculatePath();
this.dirty = false;
}
let start = this.start() * this.arcLength;
let end = this.end() * this.arcLength;
if (start > end) {
@@ -285,7 +292,7 @@ export class Arrow extends Shape<ArrowConfig> {
context.closePath();
}
private calculatePath() {
public recalculateLayout() {
this.arcLength = 0;
this.segments = [];
@@ -356,42 +363,10 @@ export class Arrow extends Shape<ArrowConfig> {
);
this.segments.push(line);
this.arcLength += line.arcLength;
}
public markAsDirty() {
this.dirty = true;
super.recalculateLayout();
}
public getArrowSize(): number {
return this.attrs.arrowSize ?? this.strokeWidth() * 2;
}
radius: GetSet<number, this>;
points: GetSet<number[], this>;
start: GetSet<number, this>;
end: GetSet<number, this>;
arrowSize: GetSet<number, this>;
}
Arrow.prototype.className = 'Arrow';
Arrow.prototype._attrsAffectingSize = ['points', 'radius'];
_registerNode(Arrow);
Factory.addGetterSetter(
Arrow,
'radius',
8,
getNumberValidator(),
Arrow.prototype.markAsDirty,
);
Factory.addGetterSetter(
Arrow,
'points',
[],
getNumberArrayValidator(),
Arrow.prototype.markAsDirty,
);
Factory.addGetterSetter(Arrow, 'start', 0, getNumberValidator());
Factory.addGetterSetter(Arrow, 'end', 1, getNumberValidator());
Factory.addGetterSetter(Arrow, 'arrowSize', 16, getNumberValidator());

View File

@@ -8,6 +8,7 @@ import {Origin} from '../types';
import {Style} from '../styles';
import {GetSet} from 'konva/lib/types';
import {clampRemap} from '../tweening';
import {useScene} from '../utils';
export interface ColorPickerConfig extends LinearLayoutConfig {
previewColor?: string;
@@ -108,11 +109,10 @@ export class ColorPicker extends Surface {
this.g.opacity(opacity);
this.b.opacity(opacity);
this.a.opacity(opacity);
this.fireLayoutChange();
this.markDirty();
}
protected handleLayoutChange() {
public recalculateLayout() {
if (this.preview) {
const rangeWidth = this.preview.width() - 80;
this.r.width(rangeWidth);
@@ -120,6 +120,6 @@ export class ColorPicker extends Surface {
this.b.width(rangeWidth);
this.a.width(rangeWidth);
}
super.handleLayoutChange();
super.recalculateLayout();
}
}

View File

@@ -1,7 +1,7 @@
import {Node} from 'konva/lib/Node';
import {Center} from '../types';
import {KonvaNode} from '../decorators';
import {Pin, PIN_CHANGE_EVENT} from './Pin';
import {Pin} from './Pin';
import {Group} from 'konva/lib/Group';
import {ContainerConfig} from 'konva/lib/Container';
import {Arrow} from './Arrow';
@@ -41,20 +41,17 @@ export class Connection extends Group {
super(config);
this.start = config?.start ?? new Pin();
this.start.on(PIN_CHANGE_EVENT, () => this.recalculate());
if (!this.start.getParent()) {
this.add(this.start);
}
this.end = config?.end ?? new Pin();
this.end.on(PIN_CHANGE_EVENT, () => this.recalculate());
if (!this.end.getParent()) {
this.add(this.end);
}
if (config?.crossing) {
this.crossing = config.crossing;
this.crossing.on('absoluteTransformChange', () => this.recalculate());
}
this.arrow = config?.arrow ?? new Arrow();
@@ -153,7 +150,25 @@ export class Connection extends Group {
: clampedCrossing;
}
private recalculate() {
public isDirty(): boolean {
return this.attrs.dirty || this.start.wasDirty() || this.end.wasDirty() || this.crossing?.wasDirty();
}
public updateLayout() {
this.start.updateLayout();
this.end.updateLayout();
this.attrs.wasDirty = false;
if (this.isDirty()) {
this.recalculateLayout();
this.attrs.dirty = false;
this.attrs.wasDirty = true;
}
this.arrow.updateLayout();
}
public recalculateLayout() {
if (!this.start || !this.end || !this.arrow) {
this.arrow?.points([]);
return;
@@ -240,5 +255,6 @@ export class Connection extends Group {
this.arrow.radius(Math.min(8, distance / 2));
this.arrow.points(points);
super.recalculateLayout();
}
}

View File

@@ -1,7 +1,6 @@
import {Shape, ShapeConfig} from 'konva/lib/Shape';
import {Node} from 'konva/lib/Node';
import {Context} from 'konva/lib/Context';
import {isLayoutNode} from './ILayoutNode';
import {GetSet} from 'konva/lib/types';
import {getset} from '../decorators';
@@ -29,7 +28,6 @@ export class Debug extends Shape<DebugConfig> {
const position = target.getAbsolutePosition(this.getLayer());
const scale = target.getAbsoluteScale(this.getLayer());
if (isLayoutNode(target)) {
const ctx = context._context;
const contentRect = target.getPadd().scale(scale).shrink(rect);
const marginRect = target.getMargin().scale(scale).expand(rect);
@@ -66,13 +64,5 @@ export class Debug extends Shape<DebugConfig> {
ctx.fill();
ctx.restore();
} else {
context.beginPath();
context.rect(rect.x, rect.y, rect.width, rect.height);
context.moveTo(position.x, position.y);
context.arc(position.x, position.y, 4, 0, Math.PI * 2, false);
context.closePath();
context.strokeShape(this);
}
}
}

View File

@@ -1,17 +1,17 @@
import {Context} from 'konva/lib/Context';
import {GetSet} from 'konva/lib/types';
import {LayoutShape, LayoutShapeConfig} from './LayoutShape';
import {KonvaNode, getset} from '../decorators';
import {CanvasHelper} from '../helpers';
import {Shape, ShapeConfig} from 'konva/lib/Shape';
export interface GridConfig extends LayoutShapeConfig {
export interface GridConfig extends ShapeConfig {
gridSize?: number;
subdivision?: boolean;
checker?: boolean;
}
@KonvaNode()
export class Grid extends LayoutShape {
export class Grid extends Shape {
@getset(16, Grid.prototype.recalculate)
public gridSize: GetSet<number, this>;
@getset(false)

View File

@@ -1,96 +0,0 @@
import {
Size,
Origin,
PossibleSpacing,
Spacing,
originPosition,
} from '../types';
import {Node} from 'konva/lib/Node';
import {LayoutGroup} from './LayoutGroup';
import {LayoutShape} from './LayoutShape';
import {IRect, Vector2d} from 'konva/lib/types';
import {Shape} from 'konva/lib/Shape';
import {Group} from 'konva/lib/Group';
export const LAYOUT_CHANGE_EVENT = 'layoutChange';
export interface LayoutAttrs {
margin: PossibleSpacing;
padd: PossibleSpacing;
origin: Origin;
}
export interface ILayoutNode {
getMargin(): Spacing;
getPadd(): Spacing;
getOrigin(): Origin;
getOriginOffset(custom?: Partial<LayoutAttrs>): Vector2d;
getOriginDelta(newOrigin: Origin, custom?: Partial<LayoutAttrs>): Vector2d;
getLayoutSize(custom?: Partial<LayoutAttrs>): Size;
}
export type LayoutNode = (Shape | Group) & ILayoutNode;
export function isLayoutNode(node: Node): node is LayoutNode {
return 'getLayoutSize' in node;
}
export function isInsideLayout(node: Node) {
let parent = node.getParent();
let limit = 100;
while (parent && limit > 0) {
if (parent instanceof LayoutGroup || parent instanceof LayoutShape)
return true;
limit--;
parent = parent.getParent();
}
if (limit <= 0) {
console.warn('isInsideLayout has reached its limit!');
}
return false;
}
export function getOriginOffset(size: Size, origin: Origin): Vector2d {
return originPosition(origin, size.width / 2, size.height / 2);
}
export function getOriginDelta(size: Size, from: Origin, to: Origin) {
const fromOffset = getOriginOffset(size, from);
if (to === Origin.Middle) {
return {x: -fromOffset.x, y: -fromOffset.y};
}
const toOffset = getOriginOffset(size, to);
return {
x: toOffset.x - fromOffset.x,
y: toOffset.y - fromOffset.y,
};
}
export function getClientRect(
node: LayoutNode,
config?: {
skipTransform?: boolean;
skipShadow?: boolean;
skipStroke?: boolean;
relativeTo?: Node;
},
): IRect {
const size = node.getLayoutSize();
const offset = node.getOriginOffset({origin: Origin.TopLeft});
const rect: IRect = {
x: offset.x,
y: offset.y,
width: size.width,
height: size.height,
};
if (!config?.skipTransform) {
return node._transformedRect(rect, config?.relativeTo);
}
return rect;
}

View File

@@ -1,6 +1,6 @@
import {LayoutShape, LayoutShapeConfig} from './LayoutShape';
import {KonvaNode} from '../decorators';
import {Context} from 'konva/lib/Context';
import {Shape, ShapeConfig} from 'konva/lib/Shape';
const FILL = [
new Path2D(
@@ -26,12 +26,12 @@ export enum IconType {
Unity,
}
interface IconConfig extends LayoutShapeConfig {
interface IconConfig extends ShapeConfig {
type?: IconType;
}
@KonvaNode()
export class Icon extends LayoutShape {
@KonvaNode({centroid: false})
export class Icon extends Shape {
private readonly paths: Path2D[];
constructor(config?: IconConfig) {

View File

@@ -1,54 +1,11 @@
import {LayoutGroup, LayoutGroupConfig} from './LayoutGroup';
import {LayoutAttrs} from './ILayoutNode';
import {Size} from '../types';
import {IRect, Vector2d} from 'konva/lib/types';
import {Group} from 'konva/lib/Group';
import {Shape} from 'konva/lib/Shape';
import {Container} from 'konva/lib/Container';
import {Container, ContainerConfig} from 'konva/lib/Container';
export class LayeredLayout extends LayoutGroup {
add(...children: (Group | Shape)[]): this {
super.add(...children);
this.handleLayoutChange();
return this;
}
protected handleLayoutChange() {
this.offset(this.getOriginOffset());
this.fireLayoutChange();
}
public getLayoutSize(custom?: Partial<LayoutAttrs>): Size {
return this.getClientRect({skipTransform: true});
}
public getOriginOffset(custom?: LayoutGroupConfig): Vector2d {
const offset = super.getOriginOffset(custom);
const rect = this.getClientRect({relativeTo: this});
return {
x: offset.x + rect.x + rect.width / 2,
y: offset.y + rect.y + rect.height / 2,
};
}
getClientRect(config?: {
skipTransform?: boolean;
skipShadow?: boolean;
skipStroke?: boolean;
relativeTo?: Container;
}): IRect {
const rect = this.getPadd().expand(
super.getClientRect({
...config,
skipTransform: true,
}),
);
if (!config?.skipTransform) {
return this._transformedRect(rect, config?.relativeTo);
}
return rect;
export class LayeredLayout extends Group {
public getLayoutSize(custom?: ContainerConfig): Size {
return this.getChildrenRect({skipTransform: true});
}
}

View File

@@ -1,104 +0,0 @@
import {Group} from 'konva/lib/Group';
import {ContainerConfig} from 'konva/lib/Container';
import {Vector2d} from 'konva/lib/types';
import {Origin, Size, PossibleSpacing, Spacing} from '../types';
import {
getOriginDelta,
getOriginOffset,
ILayoutNode,
isInsideLayout,
LAYOUT_CHANGE_EVENT,
LayoutAttrs,
} from './ILayoutNode';
import {Node} from 'konva/lib/Node';
export type LayoutGroupConfig = Partial<LayoutAttrs> & ContainerConfig;
export abstract class LayoutGroup extends Group implements ILayoutNode {
public constructor(config?: LayoutGroupConfig) {
super({
color: '#242424',
radius: 8,
...config,
});
this.on(LAYOUT_CHANGE_EVENT, () => this.handleLayoutChange());
this.handleLayoutChange();
}
public abstract getLayoutSize(custom?: LayoutGroupConfig): Size;
public setMargin(value: PossibleSpacing): this {
this.attrs.margin = new Spacing(value);
this.fireLayoutChange();
return this;
}
public getMargin(): Spacing {
return this.attrs.margin ?? new Spacing();
}
public setPadd(value: PossibleSpacing): this {
this.attrs.padd = new Spacing(value);
this.fireLayoutChange();
return this;
}
public getPadd(): Spacing {
return this.attrs.padd ?? new Spacing();
}
public setOrigin(value: Origin): this {
if (!isInsideLayout(this)) {
this.move(this.getOriginDelta(value));
}
this.attrs.origin = value;
this.offset(this.getOriginOffset());
this.fireLayoutChange();
return this;
}
public findOne<ChildNode extends Node>(
selector: string | Function | (new (...args: any[]) => ChildNode),
): ChildNode {
//@ts-ignore
return super.findOne<ChildNode>(selector.prototype?.className ?? selector);
}
public getOrigin(): Origin {
return this.attrs.origin ?? Origin.Middle;
}
public withOrigin(origin: Origin, action: () => void) {
const previousOrigin = this.getOrigin();
this.setOrigin(origin);
action();
this.setOrigin(previousOrigin);
}
public getOriginOffset(custom?: LayoutGroupConfig): Vector2d {
return getOriginOffset(
this.getLayoutSize(custom),
custom?.origin ?? this.getOrigin(),
);
}
public getOriginDelta(newOrigin: Origin, custom?: LayoutGroupConfig) {
return getOriginDelta(
this.getLayoutSize(custom),
custom?.origin ?? this.getOrigin(),
newOrigin,
);
}
protected fireLayoutChange() {
this.getParent()?.fire(LAYOUT_CHANGE_EVENT, undefined, true);
}
public _setChildrenIndices() {
super._setChildrenIndices();
this.handleLayoutChange();
}
protected handleLayoutChange() {}
}

View File

@@ -1,109 +0,0 @@
import {Container} from 'konva/lib/Container';
import {Shape, ShapeConfig} from 'konva/lib/Shape';
import {
getOriginDelta,
getOriginOffset,
ILayoutNode,
isInsideLayout,
LAYOUT_CHANGE_EVENT,
LayoutAttrs,
getClientRect,
} from './ILayoutNode';
import {Origin, Size, PossibleSpacing, Spacing} from '../types';
import {IRect, Vector2d} from 'konva/lib/types';
export type LayoutShapeConfig = Partial<LayoutAttrs> & ShapeConfig;
export abstract class LayoutShape extends Shape implements ILayoutNode {
public constructor(config?: LayoutShapeConfig) {
super(config);
this.on(LAYOUT_CHANGE_EVENT, () => this.handleLayoutChange());
this.handleLayoutChange();
}
public getLayoutSize(custom?: LayoutShapeConfig): Size {
const padding =
custom?.padd === null || custom?.padd === undefined
? this.getPadd()
: new Spacing(custom.padd);
return padding.expand(this.getSize());
}
public setMargin(value: PossibleSpacing): this {
this.attrs.margin = new Spacing(value);
this.fireLayoutChange();
return this;
}
public getMargin(): Spacing {
return this.attrs.margin ?? new Spacing();
}
public setPadd(value: PossibleSpacing): this {
this.attrs.padd = new Spacing(value);
this.fireLayoutChange();
return this;
}
public getPadd(): Spacing {
return this.attrs.padd ?? new Spacing();
}
public setOrigin(value: Origin): this {
if (!isInsideLayout(this)) {
this.move(this.getOriginDelta(value));
}
this.attrs.origin = value;
this.offset(this.getOriginOffset());
this.fireLayoutChange();
return this;
}
public getOrigin(): Origin {
return this.attrs.origin ?? Origin.Middle;
}
public withOrigin(origin: Origin, action: () => void) {
const previousOrigin = this.getOrigin();
this.setOrigin(origin);
action();
this.setOrigin(previousOrigin);
}
public getOriginOffset(custom?: LayoutShapeConfig): Vector2d {
return getOriginOffset(
this.getLayoutSize(custom),
custom?.origin ?? this.getOrigin(),
);
}
public getOriginDelta(newOrigin: Origin, custom?: LayoutShapeConfig) {
return getOriginDelta(
this.getLayoutSize(custom),
custom?.origin ?? this.getOrigin(),
newOrigin,
);
}
public getClientRect(config?: {
skipTransform?: boolean;
skipShadow?: boolean;
skipStroke?: boolean;
relativeTo?: Container;
}): IRect {
return getClientRect(this, config);
}
public subscribe(event: string, handler: () => void): () => void {
this.on(event, handler);
return () => this.off(event, handler);
}
protected fireLayoutChange() {
this.getParent()?.fire(LAYOUT_CHANGE_EVENT, undefined, true);
}
protected handleLayoutChange() {}
}

View File

@@ -1,23 +1,15 @@
import {Text, TextConfig} from 'konva/lib/shapes/Text';
import {GetSet, IRect, Vector2d} from 'konva/lib/types';
import {ShapeGetClientRectConfig} from 'konva/lib/Shape';
import {
getOriginDelta,
getOriginOffset,
ILayoutNode,
isInsideLayout,
LAYOUT_CHANGE_EVENT,
LayoutAttrs,
} from './ILayoutNode';
import {Origin, Size, PossibleSpacing, Spacing} from '../types';
import {Origin, Size, PossibleSpacing, Spacing, getOriginOffset} from '../types';
import {Animator, tween, textTween, InterpolationFunction} from '../tweening';
import {getset, threadable} from '../decorators';
export interface LayoutTextConfig extends Partial<LayoutAttrs>, TextConfig {
export interface LayoutTextConfig extends TextConfig {
minWidth?: number;
}
export class LayoutText extends Text implements ILayoutNode {
export class LayoutText extends Text {
@getset('', undefined, LayoutText.prototype.textTween)
public text: GetSet<LayoutTextConfig['text'], this>;
@@ -36,9 +28,6 @@ export class LayoutText extends Text implements ILayoutNode {
...config,
});
this.isConstructed = true;
this.on(LAYOUT_CHANGE_EVENT, () => this.handleLayoutChange());
this.handleLayoutChange();
this.offset(this.getOriginOffset());
}
public getLayoutSize(custom?: LayoutTextConfig): Size {
@@ -53,29 +42,9 @@ export class LayoutText extends Text implements ILayoutNode {
};
}
public setMargin(value: PossibleSpacing): this {
this.attrs.margin = new Spacing(value);
this.fireLayoutChange();
return this;
}
public getMargin(): Spacing {
return this.attrs.margin ?? new Spacing();
}
public setPadd(value: PossibleSpacing): this {
this.attrs.padd = new Spacing(value);
this.fireLayoutChange();
return this;
}
public getPadd(): Spacing {
return this.attrs.padd ?? new Spacing();
}
public setMinWidth(value: number): this {
this.attrs.minWidth = value;
this.fireLayoutChange();
this.markDirty();
return this;
}
@@ -85,34 +54,10 @@ export class LayoutText extends Text implements ILayoutNode {
public setText(text: string): this {
super.setText(text);
this.offset(this.getOriginOffset());
this.fireLayoutChange();
this.markDirty();
return this;
}
public setOrigin(value: Origin): this {
if (!isInsideLayout(this)) {
this.move(this.getOriginDelta(value));
}
this.attrs.origin = value;
this.offset(this.getOriginOffset());
this.fireLayoutChange();
return this;
}
public getOrigin(): Origin {
return this.attrs.origin ?? Origin.Middle;
}
public withOrigin(origin: Origin, action: () => void) {
const previousOrigin = this.getOrigin();
this.setOrigin(origin);
action();
this.setOrigin(previousOrigin);
}
public getOriginOffset(custom?: LayoutTextConfig): Vector2d {
const padding = this.getPadd();
const size = this.getLayoutSize({minWidth: 0, ...custom});
@@ -149,14 +94,6 @@ export class LayoutText extends Text implements ILayoutNode {
onEnd();
}
public getOriginDelta(newOrigin: Origin, custom?: LayoutTextConfig) {
return getOriginDelta(
this.getLayoutSize(custom),
custom?.origin ?? this.getOrigin(),
newOrigin,
);
}
public getClientRect(config?: ShapeGetClientRectConfig): IRect {
const realSize = this.getLayoutSize({minWidth: 0});
const size = this.getLayoutSize();
@@ -175,10 +112,4 @@ export class LayoutText extends Text implements ILayoutNode {
return rect;
}
protected fireLayoutChange() {
this.getParent()?.fire(LAYOUT_CHANGE_EVENT, undefined, true);
}
protected handleLayoutChange() {}
}

View File

@@ -1,19 +1,19 @@
import {Group} from 'konva/lib/Group';
import {GetSet, IRect} from 'konva/lib/types';
import {Shape} from 'konva/lib/Shape';
import {LayoutGroup, LayoutGroupConfig} from './LayoutGroup';
import {Center, Origin, Size, Spacing} from '../types';
import {Container} from 'konva/lib/Container';
import {getClientRect, getOriginDelta, isLayoutNode} from './ILayoutNode';
import {Container, ContainerConfig} from 'konva/lib/Container';
import {getset, KonvaNode} from '../decorators';
import {Node} from 'konva/lib/Node';
import {Rect} from 'konva/lib/shapes/Rect';
export interface LinearLayoutConfig extends LayoutGroupConfig {
export interface LinearLayoutConfig extends ContainerConfig {
direction?: Center;
}
@KonvaNode()
export class LinearLayout extends LayoutGroup {
@getset(Center.Vertical, LinearLayout.prototype.handleLayoutChange)
export class LinearLayout extends Group {
@getset(Center.Vertical, Node.prototype.markDirty)
public direction: GetSet<Center, this>;
private contentSize: Size;
@@ -32,20 +32,19 @@ export class LinearLayout extends LayoutGroup {
//TODO Recalculate upon removing children as well.
add(...children: (Group | Shape)[]): this {
super.add(...children);
this.handleLayoutChange();
this.recalculateLayout();
return this;
}
protected handleLayoutChange() {
public recalculateLayout() {
if (!this.children) return;
const direction = this.direction();
this.contentSize = {width: 0, height: 0};
for (const child of this.children) {
const isLayout = isLayoutNode(child);
const size = isLayout ? child.getLayoutSize() : child.getSize();
const margin = isLayout ? child.getMargin() : new Spacing();
const size = child.getLayoutSize();
const margin = child.getMargin();
const scale = child.getAbsoluteScale(this);
const boxSize = {
@@ -68,24 +67,19 @@ export class LinearLayout extends LayoutGroup {
: this.contentSize.width / -2;
for (const child of this.children) {
const isLayout = isLayoutNode(child);
const size = isLayout ? child.getLayoutSize() : child.getSize();
const margin = isLayout ? child.getMargin() : new Spacing();
const size = child.getLayoutSize();
const margin = child.getMargin();
const scale = child.getAbsoluteScale(this);
if (direction === Center.Vertical) {
const offset = isLayout
? child.getOriginDelta(Origin.Top)
: getOriginDelta(size, Origin.TopLeft, Origin.Top);
const offset = child.getOriginDelta(Origin.Top);
child.position({
x: -offset.x * scale.x,
y: length + (-offset.y + margin.top) * scale.y,
});
length += (size.height + margin.y) * scale.y;
} else {
const offset = isLayout
? child.getOriginDelta(Origin.Left)
: getOriginDelta(size, Origin.TopLeft, Origin.Left);
const offset = child.getOriginDelta(Origin.Left);
child.position({
x: length + (-offset.x + margin.left) * scale.x,
y: -offset.y * scale.y,
@@ -93,17 +87,6 @@ export class LinearLayout extends LayoutGroup {
length += (size.width + margin.x) * scale.x;
}
}
this.offset(this.getOriginOffset());
this.fireLayoutChange();
}
getClientRect(config?: {
skipTransform?: boolean;
skipShadow?: boolean;
skipStroke?: boolean;
relativeTo?: Container;
}): IRect {
return getClientRect(this, config);
super.recalculateLayout();
}
}

View File

@@ -1,32 +1,28 @@
import {Group} from 'konva/lib/Group';
import {Container, ContainerConfig} from 'konva/lib/Container';
import {Center, flipOrigin, Origin} from '../types';
import {Center, flipOrigin, getOriginDelta, Origin} from '../types';
import {GetSet, IRect} from 'konva/lib/types';
import {getset, KonvaNode} from '../decorators';
import {getOriginDelta, LAYOUT_CHANGE_EVENT, LayoutNode} from './ILayoutNode';
import {Node} from 'konva/lib/Node';
import {bind} from '../decorators/bind';
export interface PinConfig extends ContainerConfig {
target?: Node;
attach?: LayoutNode;
attach?: Node;
direction?: Center;
}
export const PIN_CHANGE_EVENT = 'pinChange';
@KonvaNode()
export class Pin extends Group {
@getset(null, Pin.prototype.firePinChangeEvent)
@getset(null, Node.prototype.markDirty)
public target: GetSet<PinConfig['target'], this>;
@getset(null, Pin.prototype.updateAttach)
@getset(null, Node.prototype.markDirty)
public attach: GetSet<PinConfig['attach'], this>;
@getset(null, Pin.prototype.firePinChangeEvent)
@getset(null, Pin.prototype.markDirty)
public direction: GetSet<PinConfig['direction'], this>;
public constructor(config?: PinConfig) {
super(config);
this.on('absoluteTransformChange', this.firePinChangeEvent);
}
public getDirection(): Center {
@@ -36,30 +32,11 @@ export class Pin extends Group {
);
}
public destroy(): this {
this.attrs.target?.off('absoluteTransformChange', this.firePinChangeEvent);
this.attrs.target?.off(LAYOUT_CHANGE_EVENT, this.firePinChangeEvent);
return super.destroy();
public isDirty(): boolean {
return super.isDirty() || this.target()?.wasDirty();
}
public setTarget(value: Node): this {
this.attrs.target?.off('absoluteTransformChange', this.firePinChangeEvent);
this.attrs.target?.off(LAYOUT_CHANGE_EVENT, this.firePinChangeEvent);
this.attrs.target = value;
this.attrs.target?.on('absoluteTransformChange', this.firePinChangeEvent);
this.attrs.target?.on(LAYOUT_CHANGE_EVENT, this.firePinChangeEvent);
this.firePinChangeEvent();
return this;
}
@bind()
private firePinChangeEvent() {
this.updateAttach();
this.fire(PIN_CHANGE_EVENT, undefined, true);
}
private updateAttach() {
public recalculateLayout() {
const attach = this.attach();
if (attach) {
const attachDirection = flipOrigin(attach.getOrigin(), this.direction());
@@ -70,6 +47,7 @@ export class Pin extends Group {
y: rect.y + offset.y,
});
}
super.recalculateLayout();
}
public getClientRect(config?: {

View File

@@ -1,12 +1,12 @@
import {LayoutShape, LayoutShapeConfig} from './LayoutShape';
import {Context} from 'konva/lib/Context';
import {getset, KonvaNode} from '../decorators';
import {CanvasHelper} from '../helpers';
import {GetSet} from 'konva/lib/types';
import {getFontColor, getStyle, Style} from '../styles';
import {remap} from '../tweening';
import {Shape, ShapeConfig} from 'konva/lib/Shape';
export interface RangeConfig extends LayoutShapeConfig {
export interface RangeConfig extends ShapeConfig {
range?: [number, number];
value?: number;
precision?: number;
@@ -15,7 +15,7 @@ export interface RangeConfig extends LayoutShapeConfig {
}
@KonvaNode()
export class Range extends LayoutShape {
export class Range extends Shape {
@getset(null)
public style: GetSet<RangeConfig['style'], this>;
@getset([0, 1])

View File

@@ -1,13 +1,13 @@
import {Context} from 'konva/lib/Context';
import {Util} from 'konva/lib/Util';
import {GetSet, Vector2d} from 'konva/lib/types';
import {LayoutShape, LayoutShapeConfig} from './LayoutShape';
import {waitFor} from '../animations';
import {getset, KonvaNode, threadable} from '../decorators';
import {GeneratorHelper} from '../helpers';
import {InterpolationFunction, map, tween} from '../tweening';
import {cancel, ThreadGenerator} from '../threading';
import {parseColor} from 'mix-color';
import {Shape, ShapeConfig} from 'konva/lib/Shape';
export interface SpriteData {
fileName: string;
@@ -17,7 +17,7 @@ export interface SpriteData {
height: number;
}
export interface SpriteConfig extends LayoutShapeConfig {
export interface SpriteConfig extends ShapeConfig {
animation: SpriteData[];
skin?: SpriteData;
mask?: SpriteData;
@@ -32,7 +32,7 @@ export const SPRITE_CHANGE_EVENT = 'spriteChange';
const COMPUTE_CANVAS_SIZE = 64;
@KonvaNode()
export class Sprite extends LayoutShape {
export class Sprite extends Shape {
@getset(null, Sprite.prototype.recalculate)
public animation: GetSet<SpriteConfig['animation'], this>;
@getset(null, Sprite.prototype.recalculate)
@@ -145,7 +145,7 @@ export class Sprite extends LayoutShape {
this.context.putImageData(this.imageData, 0, 0);
this.fire(SPRITE_CHANGE_EVENT);
this.fireLayoutChange();
this.markDirty();
}
public play(): ThreadGenerator {

View File

@@ -1,21 +1,16 @@
import {Container} from 'konva/lib/Container';
import {Container, ContainerConfig} from 'konva/lib/Container';
import {Rect} from 'konva/lib/shapes/Rect';
import {parseColor} from 'mix-color';
import {LayoutGroup, LayoutGroupConfig} from './LayoutGroup';
import {Origin, Size} from '../types';
import {
getClientRect,
getOriginDelta,
getOriginOffset,
LayoutNode,
} from './ILayoutNode';
import {getOriginDelta, getOriginOffset, Origin, Size} from '../types';
import {CanvasHelper} from '../helpers';
import {Context} from 'konva/lib/Context';
import {easeOutExpo, linear, tween} from '../tweening';
import {GetSet, IRect} from 'konva/lib/types';
import {getset, threadable} from '../decorators';
import {getset, KonvaNode, threadable} from '../decorators';
import {Node} from 'konva/lib/Node';
import {Reference} from '../utils';
import {Group} from 'konva/lib/Group';
import {Shape} from 'konva/lib/Shape';
export interface SurfaceMask {
width: number;
@@ -30,17 +25,18 @@ export interface CircleMask {
y: number;
}
export interface SurfaceConfig extends LayoutGroupConfig {
export interface SurfaceConfig extends ContainerConfig {
ref?: Reference<Surface>;
radius?: number;
origin?: Origin;
circleMask?: CircleMask;
background?: string;
child?: LayoutNode;
child?: Node;
}
export class Surface extends LayoutGroup {
@getset(0, Surface.prototype.updateBackground)
@KonvaNode()
export class Surface extends Group {
@getset(8, Surface.prototype.updateBackground)
public radius: GetSet<SurfaceConfig['radius'], this>;
@getset('#FF00FF', Surface.prototype.updateBackground)
public background: GetSet<SurfaceConfig['background'], this>;
@@ -70,21 +66,21 @@ export class Surface extends LayoutGroup {
});
this.add(this.ripple, this.box);
this.handleLayoutChange();
this.markDirty();
}
public setChild(value: LayoutNode): this {
public setChild(value: Shape | Group): this {
this.attrs.child?.remove();
this.attrs.child = value;
if (value) {
this.add(value);
}
this.handleLayoutChange();
this.markDirty();
return this;
}
public getChild<T extends LayoutNode>(): T {
public getChild<T extends Node>(): T {
return <T>this.attrs.child;
}
@@ -160,7 +156,7 @@ export class Surface extends LayoutGroup {
public setMask(data: SurfaceMask) {
if (data === null) {
this.surfaceMask = null;
this.handleLayoutChange();
this.markDirty();
return;
} else if (this.surfaceMask === null) {
this.box
@@ -186,7 +182,7 @@ export class Surface extends LayoutGroup {
child.scaleY(scale);
child.position(getOriginDelta(data, Origin.Middle, child.getOrigin()));
this.box.fill(data.color);
this.fireLayoutChange();
this.markDirty();
}
public getMask(): SurfaceMask {
@@ -197,7 +193,7 @@ export class Surface extends LayoutGroup {
};
}
protected handleLayoutChange() {
public recalculateLayout() {
if (this.surfaceMask) return;
this.layoutData ??= {
@@ -226,8 +222,7 @@ export class Surface extends LayoutGroup {
this.updateBox();
this.updateBackground();
this.setOrigin(this.getOrigin());
this.fireLayoutChange();
super.recalculateLayout();
}
private updateBox() {
@@ -296,13 +291,4 @@ export class Surface extends LayoutGroup {
radius: distance * mask.radius,
};
}
getClientRect(config?: {
skipTransform?: boolean;
skipShadow?: boolean;
skipStroke?: boolean;
relativeTo?: Container;
}): IRect {
return getClientRect(this, config);
}
}

View File

@@ -1,13 +1,13 @@
import {LayoutShape, LayoutShapeConfig} from './LayoutShape';
import {PossibleSpacing, Size} from '../types';
import {Util} from 'konva/lib/Util';
import {Context} from 'konva/lib/Context';
import * as THREE from 'three';
import {CanvasHelper} from '../helpers';
import {GetSet} from "konva/lib/types";
import {getset} from "../decorators";
import {getset, KonvaNode} from '../decorators';
import {Shape, ShapeConfig} from 'konva/lib/Shape';
export interface ThreeViewConfig extends LayoutShapeConfig {
export interface ThreeViewConfig extends ShapeConfig {
canvasSize: Size;
cameraScale?: number;
quality?: number;
@@ -44,7 +44,8 @@ class RendererPool implements Pool<THREE.WebGLRenderer> {
const rendererPool = new RendererPool();
export class ThreeView extends LayoutShape {
@KonvaNode()
export class ThreeView extends Shape {
@getset(null)
public scene: GetSet<ThreeViewConfig['scene'], this>
@getset(null)
@@ -117,7 +118,7 @@ export class ThreeView extends LayoutShape {
size.width *= this.quality();
size.height *= this.quality();
this.renderer.setSize(size.width, size.height);
this.fireLayoutChange();
this.markDirty();
}
getLayoutSize(): Size {

View File

@@ -1,5 +1,9 @@
export function KonvaNode(): ClassDecorator {
return function(target) {
target.prototype.className = target.name;
}
}
export function KonvaNode(config?: {
name?: string;
centroid?: boolean;
}): ClassDecorator {
return function (target) {
target.prototype.className = config?.name ?? target.name;
target.prototype._centroid = config?.centroid ?? true;
};
}

View File

@@ -2,7 +2,7 @@ import type {Scene} from './Scene';
import {Node, NodeConfig} from 'konva/lib/Node';
import {Container} from 'konva/lib/Container';
import {Surface} from './components';
import {LayoutNode} from './components/ILayoutNode';
import {Shape} from 'konva/lib/Shape';
function isConstructor(fn: Function): fn is new (...args: any[]) => any {
return !!fn.prototype?.name;
@@ -34,7 +34,7 @@ export function jsx(
const node = new type(rest);
if (children) {
if (node instanceof Surface) {
node.setChild(<LayoutNode>flatChildren[0]);
node.setChild(<Shape>flatChildren[0]);
} else if (node instanceof Container) {
node.add(...flatChildren);
}

36
src/patches/Container.ts Normal file
View File

@@ -0,0 +1,36 @@
import {Node} from 'konva/lib/Node';
import {Container} from 'konva/lib/Container';
import {IRect} from 'konva/lib/types';
declare module 'konva/lib/Container' {
export interface Container {
getChildrenRect(config?: {
skipTransform?: boolean;
skipShadow?: boolean;
skipStroke?: boolean;
relativeTo?: Container<Node>;
}): IRect;
}
}
Container.prototype.updateLayout = function (this: Container): void {
for (const child of this.children) {
child.updateLayout();
if (child.wasDirty()) {
this.markDirty();
}
}
Node.prototype.updateLayout.call(this);
};
Container.prototype._centroid = true;
const super_setChildrenIndices = Container.prototype._setChildrenIndices;
Container.prototype._setChildrenIndices = function (this: Container) {
super_setChildrenIndices.call(this);
this.markDirty();
};
Container.prototype.getChildrenRect = Container.prototype.getClientRect;
Container.prototype.getClientRect = Node.prototype.getClientRect;

226
src/patches/Node.ts Normal file
View File

@@ -0,0 +1,226 @@
import {Node, NodeConfig} from 'konva/lib/Node';
import {
Origin,
PossibleSpacing,
Size,
Spacing,
getOriginDelta,
getOriginOffset,
} from '../types';
import {GetSet, IRect, Vector2d} from 'konva/lib/types';
import {Factory} from 'konva/lib/Factory';
import {Rect} from 'konva/lib/shapes/Rect';
import {Container} from 'konva/lib/Container';
declare module 'konva/lib/Node' {
export interface Node {
_centroid: boolean;
padd: GetSet<PossibleSpacing, this>;
margin: GetSet<PossibleSpacing, this>;
origin: GetSet<Origin, this>;
drawOrigin: GetSet<Origin, this>;
setX(value: number): this;
setY(value: number): this;
setWidth(width: any): void;
setHeight(height: any): void;
setPadd(value: PossibleSpacing): this;
setMargin(value: PossibleSpacing): this;
setOrigin(value: Origin): this;
getPadd(): Spacing;
getMargin(): Spacing;
getOrigin(): Origin;
getLayoutSize(custom?: NodeConfig): Size;
getOriginOffset(custom?: NodeConfig): Vector2d;
getOriginDelta(newOrigin: Origin, custom?: NodeConfig): Vector2d;
/**
* Update the layout of this node and all its children.
*
* If the node is considered dirty the {@see recalculateLayout} method will be called.
*/
updateLayout(): void;
/**
* Perform any computations necessary to update the layout of this node.
*/
recalculateLayout(): void;
/**
* Mark this node as dirty.
*
* It will cause the layout of this node and all its ancestors to be recalculated before drawing the next frame.
*/
markDirty(): void;
/**
* Check if this node is dirty.
*/
isDirty(): boolean;
/**
* Check if the layout of this node has been recalculated during the current layout process.
*
* Containers can use this method to check if their children has changed.
*/
wasDirty(): boolean;
subscribe(event: string, handler: () => void): () => void;
}
export interface NodeConfig {
margin?: PossibleSpacing;
padd?: PossibleSpacing;
origin?: Origin;
}
}
Node.prototype.setPadd = function (this: Node, value: PossibleSpacing) {
this.attrs.padd = new Spacing(value);
this.markDirty();
return this;
};
Node.prototype.getPadd = function (this: Node) {
return this.attrs.padd ?? new Spacing();
};
Node.prototype.setMargin = function (this: Node, value: PossibleSpacing) {
this.attrs.margin = new Spacing(value);
this.markDirty();
return this;
};
Node.prototype.getMargin = function (this: Node) {
return this.attrs.margin ?? new Spacing();
};
Node.prototype.setOrigin = function (this: Node, value: Origin) {
this.attrs.origin = value;
this.markDirty();
return this;
};
Node.prototype.getLayoutSize = function (
this: Node,
custom?: NodeConfig,
): Size {
const padding =
custom?.padd === null || custom?.padd === undefined
? this.getPadd()
: new Spacing(custom.padd);
return padding.expand(this.getSize());
};
Node.prototype.getOriginOffset = function (
this: Node,
custom?: NodeConfig,
): Vector2d {
return getOriginDelta(
this.getLayoutSize(custom),
this._centroid ? Origin.Middle : Origin.TopLeft,
// Origin.Middle,
custom?.origin ?? this.getOrigin(),
);
};
Node.prototype.getOriginDelta = function (
this: Node,
newOrigin?: Origin,
custom?: NodeConfig,
): Vector2d {
return getOriginDelta(
this.getLayoutSize(custom),
custom?.origin ?? this.getOrigin(),
newOrigin,
);
};
Node.prototype.updateLayout = function (this: Node): void {
this.attrs.wasDirty = false;
if (this.isDirty()) {
this.recalculateLayout();
this.attrs.dirty = false;
this.attrs.wasDirty = true;
}
};
Node.prototype.recalculateLayout = function (this: Node): void {
this.attrs.dirty = false;
this.offset(this.getOriginOffset());
};
Node.prototype.markDirty = function (this: Node): void {
this.attrs.dirty = true;
};
Node.prototype.isDirty = function (this: Node): boolean {
return this.attrs.dirty;
};
Node.prototype.wasDirty = function (this: Node): boolean {
return this.attrs.wasDirty;
};
Node.prototype.subscribe = function (
this: Node,
event: string,
handler: () => void,
): () => void {
this.on(event, handler);
return () => this.off(event, handler);
};
Node.prototype.getClientRect = function (
this: Node,
config?: {
skipTransform?: boolean;
skipShadow?: boolean;
skipStroke?: boolean;
relativeTo?: Container;
},
): IRect {
const size = this.getLayoutSize();
const offset = this.getOriginOffset({origin: Origin.TopLeft});
const rect: IRect = {
x: offset.x,
y: offset.y,
width: size.width,
height: size.height,
};
if (!config?.skipTransform) {
return this._transformedRect(rect, config?.relativeTo);
}
return rect;
};
const super_setX = Node.prototype.setX;
Node.prototype.setX = function (this: Node, value: number) {
if (this.attrs.x !== value) this.markDirty();
return super_setX.call(this, value);
};
const super_setY = Node.prototype.setY;
Node.prototype.setY = function (this: Node, value: number) {
if (this.attrs.y !== value) this.markDirty();
return super_setY.call(this, value);
};
const super_setWidth = Node.prototype.setWidth;
Node.prototype.setWidth = function (this: Node, value: number) {
if (this.attrs.width !== value) this.markDirty();
return super_setWidth.call(this, value);
};
const super_setHeight = Node.prototype.setHeight;
Node.prototype.setHeight = function (this: Node, value: number) {
if (this.attrs.height !== value) this.markDirty();
return super_setHeight.call(this, value);
};
Factory.addGetterSetter(Node, 'padd');
Factory.addGetterSetter(Node, 'margin');
Factory.addGetterSetter(Node, 'origin');

17
src/patches/Shape.ts Normal file
View File

@@ -0,0 +1,17 @@
import {Node} from 'konva/lib/Node';
import {Shape, ShapeGetClientRectConfig} from 'konva/lib/Shape';
import {IRect} from 'konva/lib/types';
declare module 'konva/lib/Shape' {
export interface Shape {
getShapeRect(config?: ShapeGetClientRectConfig): {
width: number;
height: number;
x: number;
y: number;
};
}
}
Shape.prototype.getShapeRect = Shape.prototype.getClientRect;
Shape.prototype.getClientRect = Node.prototype.getClientRect;

View File

@@ -1 +1,4 @@
import './Factory';
import './Node';
import './Shape';
import './Container';

View File

@@ -1,4 +1,5 @@
import {Vector2d} from "konva/lib/types";
import {Size} from './Size';
export enum Center {
Vertical = 1,
@@ -69,4 +70,21 @@ export function originPosition(origin: Origin | Direction, width = 1 , height =
}
return position;
}
export function getOriginOffset(size: Size, origin: Origin): Vector2d {
return originPosition(origin, size.width / 2, size.height / 2);
}
export function getOriginDelta(size: Size, from: Origin, to: Origin) {
const fromOffset = getOriginOffset(size, from);
if (to === Origin.Middle) {
return {x: -fromOffset.x, y: -fromOffset.y};
}
const toOffset = getOriginOffset(size, to);
return {
x: toOffset.x - fromOffset.x,
y: toOffset.y - fromOffset.y,
};
}

View File

@@ -1,3 +1,4 @@
export * from './slide';
export * from './pop';
export * from './useRef';
export * from './useScene';

23
src/utils/slide.ts Normal file
View File

@@ -0,0 +1,23 @@
import {Container} from 'konva/lib/Container';
import {Vector2d} from 'konva/lib/types';
export function slide(container: Container, offset: Vector2d): void;
export function slide(container: Container, x: number, y?: number): void;
export function slide(
container: Container,
offset: number | Vector2d,
y: number = 0,
): void {
if (typeof offset === 'number') {
offset = {x: offset, y};
} else {
offset = {...offset};
}
container.move(offset);
offset.x *= -1;
offset.y *= -1;
for (const child of container.children) {
child.move(offset);
}
}