diff --git a/src/core/react/Grid.stories.tsx b/src/core/react/Grid.stories.tsx index f9a62c26..16e194f2 100644 --- a/src/core/react/Grid.stories.tsx +++ b/src/core/react/Grid.stories.tsx @@ -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; +} as Meta; const items: number[] = []; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 4ed7580c..095dfdd6 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -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'; diff --git a/src/core/utils/text-measurer.ts b/src/core/utils/text-measurer.ts new file mode 100644 index 00000000..2e88b9a9 --- /dev/null +++ b/src/core/utils/text-measurer.ts @@ -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(); +} \ No newline at end of file diff --git a/src/wireframes/components/CustomDragLayer.tsx b/src/wireframes/components/CustomDragLayer.tsx index 82f9e31b..c2bb05d4 100644 --- a/src/wireframes/components/CustomDragLayer.tsx +++ b/src/wireframes/components/CustomDragLayer.tsx @@ -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'; diff --git a/src/wireframes/components/EditorView.tsx b/src/wireframes/components/EditorView.tsx index 3ea61686..9200b698 100644 --- a/src/wireframes/components/EditorView.tsx +++ b/src/wireframes/components/EditorView.tsx @@ -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'; @@ -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 })); diff --git a/src/wireframes/components/actions/shared.ts b/src/wireframes/components/actions/shared.ts index 2a1f7637..fd9f4ce7 100644 --- a/src/wireframes/components/actions/shared.ts +++ b/src/wireframes/components/actions/shared.ts @@ -110,7 +110,7 @@ export function useAppearanceCore(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)); } }); diff --git a/src/wireframes/components/assets/Icons.tsx b/src/wireframes/components/assets/Icons.tsx index 75c5e5e5..526015d3 100644 --- a/src/wireframes/components/assets/Icons.tsx +++ b/src/wireframes/components/assets/Icons.tsx @@ -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'; @@ -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 })); diff --git a/src/wireframes/engine/index.ts b/src/wireframes/engine/index.ts new file mode 100644 index 00000000..9f64bc0d --- /dev/null +++ b/src/wireframes/engine/index.ts @@ -0,0 +1,8 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +export * from './interface'; \ No newline at end of file diff --git a/src/wireframes/engine/interface.ts b/src/wireframes/engine/interface.ts new file mode 100644 index 00000000..80f4ce0c --- /dev/null +++ b/src/wireframes/engine/interface.ts @@ -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 = (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): void; + onDoubleClick?(event: HitEvent, next: NextListener): void; + onClick?(event: HitEvent, next: NextListener): boolean; + onMouseDown?(event: HitEvent, next: NextListener): void; + onMouseDrag?(event: HitEvent, next: NextListener): void; + onMouseMove?(event: HitEvent, next: NextListener): void; + onMouseUp?(event: HitEvent, next: NextListener): 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; +} \ No newline at end of file diff --git a/src/wireframes/engine/svg/canvas/SvgCanvas.tsx b/src/wireframes/engine/svg/canvas/SvgCanvas.tsx new file mode 100644 index 00000000..1a407724 --- /dev/null +++ b/src/wireframes/engine/svg/canvas/SvgCanvas.tsx @@ -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(); + + 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 ( +
+ ); +}; diff --git a/src/wireframes/engine/svg/engine.stories.tsx b/src/wireframes/engine/svg/engine.stories.tsx new file mode 100644 index 00000000..067f3f40 --- /dev/null +++ b/src/wireframes/engine/svg/engine.stories.tsx @@ -0,0 +1,208 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import { Meta } from '@storybook/react'; +import * as React from 'react'; +import { EngineObject, Listener } from './../interface'; +import { SvgCanvasView } from './canvas/SvgCanvas'; +import { SvgEngine } from './engine'; + +const RendererHelper = ({ render }: { render: (engine: SvgEngine) => void }) => { + const [engine, setEngine] = React.useState(); + + React.useEffect(() => { + if (engine) { + engine?.doc.size(500, 500); + } + }, [engine, engine?.doc]); + + React.useEffect(() => { + if (engine) { + engine.doc.clear(); + render(engine); + } + }, [engine, render]); + + return ( +
+ +
+ ); +}; + +const HitTest = () => { + const [engine, setEngine] = React.useState(); + const [hits, setHits] = React.useState(); + + React.useEffect(() => { + if (engine) { + engine?.doc.size(500, 500); + } + }, [engine, engine?.doc]); + + React.useEffect(() => { + if (!engine) { + return; + } + + engine.doc.clear(); + + const layer = engine.layer('layer1'); + + const rect1 = layer.rect(); + rect1.fill('blue'); + rect1.strokeColor('red'); + rect1.strokeWidth(2); + rect1.plot({ x: 100, y: 150, w: 300, h: 200, rotation: 45 }); + + const rect2 = layer.rect(); + rect2.fill('yellow'); + rect2.strokeColor('green'); + rect2.strokeWidth(2); + rect2.plot({ x: 600, y: 150, w: 300, h: 200 }); + + const listener: Listener = { + onMouseMove: (event) => { + const hits = layer.hitTest(event.position.x, event.position.y); + setHits(hits); + }, + }; + + engine.subscribe(listener); + + return () => { + engine.unsubscribe(listener); + }; + + }, [engine]); + + return ( +
+ Hits: {hits?.length} + + +
+ ); +}; + +export default { + component: RendererHelper, +} as Meta; + +export const Hits = () => { + return ( + + ); +}; + +export const Rect = () => { + return ( + { + const layer = engine.layer('layer1'); + + const rect = layer.rect(); + rect.fill('blue'); + rect.strokeColor('red'); + rect.strokeWidth(2); + rect.plot({ x: 100, y: 150, w: 300, h: 200 }); + }} + /> + ); +}; + +export const Ellipse = () => { + return ( + { + const layer = engine.layer('layer1'); + + const ellipse = layer.ellipse(); + ellipse.fill('blue'); + ellipse.strokeColor('red'); + ellipse.strokeWidth(2); + ellipse.plot({ x: 100, y: 150, w: 300, h: 200 }); + }} + /> + ); +}; + +export const Line1 = () => { + return ( + { + const layer = engine.layer('layer1'); + + const line1 = layer.line(); + line1.color('red'); + line1.plot({ x1: 100, y1: 150, x2: 200, y2: 250, width: 1 }); + + const line2 = layer.line(); + line2.color('blue'); + line1.plot({ x1: 140, y1: 150, x2: 240, y2: 250, width: 1 }); + + const line3 = layer.line(); + line3.color('green'); + line1.plot({ x1: 180, y1: 150, x2: 280, y2: 250, width: 1 }); + }} + /> + ); +}; + +export const Text = () => { + return ( + { + const layer = engine.layer('layer1'); + + const text1 = layer.text(); + text1.color('white'); + text1.fill('black'); + text1.fontFamily('inherit'); + text1.fontSize('16px'); + text1.text('Hello SVG'); + text1.plot({ x: 50, y: 100, w: 200, h: 60, padding: 20 }); + + const text2 = layer.text(); + text2.color('white'); + text2.fill('red'); + text2.fontFamily('inherit'); + text2.fontSize('16px'); + text2.text('Hello SVG'); + text1.plot({ x: 50, y: 200, w: 200, h: 60, padding: 20 }); + }} + /> + ); +}; + +export const Cursors = () => { + return ( + { + const layer = engine.layer('layer1'); + + const move = layer.text(); + move.color('white'); + move.cursor('move'); + move.fill('black'); + move.fontFamily('inherit'); + move.fontSize('16px'); + move.text('move'); + move.plot({ x: 50, y: 100, w: 200, h: 60, padding: 20 }); + + const resize = layer.text(); + resize.color('white'); + resize.cursor('n-resize'); + resize.fill('red'); + resize.fontFamily('inherit'); + resize.fontSize('16px'); + resize.text('resize'); + resize.plot({ x: 50, y: 200, w: 200, h: 60, padding: 20 }); + }} + /> + ); +}; \ No newline at end of file diff --git a/src/wireframes/engine/svg/engine.ts b/src/wireframes/engine/svg/engine.ts new file mode 100644 index 00000000..8b5a5759 --- /dev/null +++ b/src/wireframes/engine/svg/engine.ts @@ -0,0 +1,238 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import * as svg from '@svgdotjs/svg.js'; +import { MathHelper, Types, Vec2 } from '@app/core'; +import { DiagramItem } from '@app/wireframes/model'; +import { Engine, EngineLayer, HitEvent, Listener } from '../interface'; +import { SvgItem } from './item'; +import { SvgLayer } from './layer'; +import { SvgRenderer } from './renderer'; +import { getElement, getSource } from './utils'; + +const NOOP_HANDLER: (value: any) => void = () => {}; + +const ROTATION_CONFIG = [ + { angle: 45, cursor: 'ne-resize' }, + { angle: 90, cursor: 'e-resize' }, + { angle: 135, cursor: 'se-resize' }, + { angle: 180, cursor: 's-resize' }, + { angle: 215, cursor: 'sw-resize' }, + { angle: 270, cursor: 'w-resize' }, + { angle: 315, cursor: 'nw-resize' }, +]; + +export class SvgEngine implements Engine { + private readonly listeners: Listener[] = []; + private readonly svgRenderer = new SvgRenderer(); + private isDragging = false; + private onClick: Function = NOOP_HANDLER; + private onKeyUp: Function = NOOP_HANDLER; + private onKeyDown: Function = NOOP_HANDLER; + private onDoubleClick: Function = NOOP_HANDLER; + private onMouseDown: Function = NOOP_HANDLER; + private onMouseDrag: Function = NOOP_HANDLER; + private onMouseMove: Function = NOOP_HANDLER; + private onMouseUp: Function = NOOP_HANDLER; + private onBlur: Function = NOOP_HANDLER; + + constructor( + public readonly doc: svg.Svg, + ) { + doc.mousemove((event: MouseEvent) => { + this.handleMouseMove(event); + this.onMouseMove(event); + }); + + doc.mousedown((event: MouseEvent) => { + this.handleMouseDown(); + this.onMouseDown(event); + }); + + window.addEventListener('blur', (event: FocusEvent) => { + this.onBlur(event); + }); + + window.document.addEventListener('keyup', (event: KeyboardEvent) => { + this.onKeyUp(event); + }); + + window.document.addEventListener('keydown', (event: KeyboardEvent) => { + this.onKeyDown(event); + }); + + window.document.addEventListener('mousemove', (event: MouseEvent) => { + if (this.isDragging) { + this.isDragging = true; + this.onMouseDrag(event); + } + }); + + window.document.addEventListener('mouseup', (event: MouseEvent) => { + if (this.isDragging) { + this.isDragging = false; + this.onMouseUp(event); + } + }); + } + + private handleMouseDown() { + this.isDragging = true; + } + + public setClickLayer(layer: EngineLayer) { + const element = getElement(layer); + + element.dblclick((event: MouseEvent) => { + this.onDoubleClick(event); + }); + + element.click((event: MouseEvent) => { + this.onClick(event); + }); + } + + public viewBox(x: number, y: number, w: number, h: number) { + return this.doc.viewbox(x, y, w, h); + } + + public layer(id: string): EngineLayer { + return new SvgLayer(this.svgRenderer, this.doc.group().id(id)); + } + + public subscribe(listener: Listener) { + this.listeners.push(listener); + this.rebuild(); + } + + public unsubscribe(listener: Listener) { + this.listeners.splice(this.listeners.indexOf(listener), 1); + this.rebuild(); + } + + private rebuild() { + this.onBlur = this.buildEvent(h => h?.onBlur?.bind(h)); + this.onClick = this.buildMouseEvent(h => h?.onClick?.bind(h)); + this.onKeyUp = this.buildEvent(h => h.onKeyUp?.bind(h)); + this.onKeyDown = this.buildEvent(h => h.onKeyDown?.bind(h)); + this.onDoubleClick = this.buildMouseEvent(h => h?.onDoubleClick?.bind(h)); + this.onMouseMove = this.buildMouseEvent(h => h?.onMouseMove?.bind(h)); + this.onMouseDown = this.buildMouseEvent(h => h?.onMouseDown?.bind(h)); + this.onMouseDrag = this.buildMouseEvent(h => h?.onMouseDrag?.bind(h)); + this.onMouseUp = this.buildMouseEvent(h => h?.onMouseUp?.bind(h)); + } + + private buildEvent(actionProvider: (listener: Listener) => Function | undefined) { + let result = NOOP_HANDLER; + for (let i = this.listeners.length - 1; i >= 0; i--) { + const handler = actionProvider(this.listeners[i]); + + if (handler) { + const next = result; + + result = event => handler(event, next); + } + } + + return result; + } + + private buildMouseEvent(actionProvider: (listener: Listener) => Function | undefined) { + const inner = this.buildEvent(actionProvider); + + if (inner === NOOP_HANDLER) { + return NOOP_HANDLER; + } + + const result = (event: MouseEvent) => { + let currentTarget: any = event.target; + let eventLayer: EngineLayer | null = null; + let eventObject: Object | null = null; + let eventItem: DiagramItem | null = null; + + while (currentTarget) { + const source = getSource(currentTarget); + + if (!eventObject && !Types.is(source, SvgLayer)) { + eventObject = source; + } + + if (!eventLayer && !Types.is(source, SvgLayer)) { + eventLayer = source; + } + + if (!eventItem && Types.is(source, SvgItem)) { + eventItem = source.shape; + } + + currentTarget = currentTarget.parentNode; + } + + const { x, y } = this.doc.point(event.pageX, event.pageY); + + const svgEvent = + new HitEvent( + event, + new Vec2(Math.round(x), Math.round(y)), + eventLayer!, + eventObject as any, + eventItem); + + inner(svgEvent); + }; + + return result; + } + + private handleMouseMove = (event: MouseEvent) => { + const cursor = findCursor(event.target); + + if (cursor?.cursor) { + document.body.style.cursor = cursor.cursor; + return; + } + + if (cursor?.angle) { + const rotation = cursor.angle; + + const rotationBase = svg.adopt(cursor?.target).transform().rotate; + const rotationTotal = MathHelper.toPositiveDegree((rotationBase || 0) + rotation); + + for (const config of ROTATION_CONFIG) { + if (rotationTotal > config.angle - 22.5 && + rotationTotal < config.angle + 22.5) { + document.body.style.cursor = config.cursor; + return; + } + } + + document.body.style.cursor = 'n-resize'; + return; + } + + document.body.style.cursor = 'default'; + }; +} + +function findCursor(element: any) { + while (element) { + const cursor = element['cursor']; + + if (cursor) { + return { cursor }; + } + + const angle = element['cursorAngle']; + if (Number.isFinite(angle)) { + return { angle, target: element }; + } + + element = element.parentNode; + } + + return null; +} \ No newline at end of file diff --git a/src/wireframes/engine/svg/item.ts b/src/wireframes/engine/svg/item.ts new file mode 100644 index 00000000..e3d48397 --- /dev/null +++ b/src/wireframes/engine/svg/item.ts @@ -0,0 +1,115 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import * as svg from '@svgdotjs/svg.js'; +import { Rect2 } from '@app/core'; +import { ShapePlugin } from '@app/wireframes/interface'; +import { DiagramItem } from '@app/wireframes/model'; +import { EngineItem } from './../interface'; +import { SvgObject } from './object'; +import { SvgRenderer } from './renderer'; +import { linkToSvg, SvgHelper } from './utils'; + +export class SvgItem extends SvgObject implements EngineItem { + private readonly group: svg.G; + private readonly selector: svg.Rect; + private currentShape: DiagramItem | null = null; + private isRendered = false; + + protected get root() { + return this.group; + } + + public get shape() { + return this.currentShape; + } + + constructor( + public readonly layer: svg.Container, + public readonly renderer: SvgRenderer, + public readonly plugin: ShapePlugin, + ) { + super(); + this.selector = new svg.Rect().fill('#ffffff').opacity(0.001); + + this.group = new svg.G(); + this.group.add(this.selector); + linkToSvg(this, this.group); + } + + public detach() { + this.remove(); + } + + public plot(shape: DiagramItem) { + if (this.currentShape === shape && this.isRendered) { + this.addToLayer(); + return; + } + + this.renderCore(shape); + + this.addToLayer(); + this.currentShape = shape; + } + + private addToLayer() { + if (!this.group.parent()) { + this.layer.add(this.group); + } + } + + private renderCore(item: DiagramItem) { + const localRect = new Rect2(0, 0, item.transform.size.x, item.transform.size.y); + + const previousContainer = this.renderer.getContainer(); + try { + this.renderer.setContainer(this.group, 1); + + this.plugin.render({ renderer2: this.renderer, rect: localRect, shape: item }); + + this.arrangeSelector(localRect); + this.arrangeContainer(item); + } finally { + this.renderer.cleanupAll(); + this.renderer.setContainer(previousContainer); + this.isRendered = true; + } + } + + private arrangeContainer(item: DiagramItem) { + const to = item.transform; + + SvgHelper.transformBy(this.group, { + x: to.position.x - 0.5 * to.size.x, + y: to.position.y - 0.5 * to.size.y, + w: to.size.x, + h: to.size.y, + rx: to.position.x, + ry: to.position.y, + rotation: to.rotation.degree, + }); + + this.group.opacity(item.opacity); + } + + private arrangeSelector(localRect: Rect2) { + let selectionRect = localRect; + + // Calculate a special selection rect, that is slightly bigger than the bounds to make selection easier. + const diffW = Math.max(0, MIN_DIMENSIONS - selectionRect.width); + const diffH = Math.max(0, MIN_DIMENSIONS - selectionRect.height); + + if (diffW > 0 || diffH > 0) { + selectionRect = selectionRect.inflate(diffW * 0.5, diffH * 0.5); + } + + SvgHelper.transformByRect(this.selector, selectionRect); + } +} + +const MIN_DIMENSIONS = 10; \ No newline at end of file diff --git a/src/wireframes/engine/svg/layer.ts b/src/wireframes/engine/svg/layer.ts new file mode 100644 index 00000000..0f791a95 --- /dev/null +++ b/src/wireframes/engine/svg/layer.ts @@ -0,0 +1,76 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import * as svg from '@svgdotjs/svg.js'; +import { ShapePlugin } from '@app/wireframes/interface'; +import { EngineItem, EngineLayer, EngineLine, EngineObject, EngineRect, EngineText } from './../interface'; +import { SvgItem } from './item'; +import { SvgLine } from './line'; +import { SvgRectOrEllipse } from './rect-or-ellipse'; +import { SvgRenderer } from './renderer'; +import { SvgText } from './text'; +import { getSource, linkToSvg } from './utils'; + +export class SvgLayer implements EngineLayer { + constructor( + private readonly renderer: SvgRenderer, + private readonly container: svg.Container, + ) { + linkToSvg(this, this.container); + } + + public ellipse(): EngineRect { + return new SvgRectOrEllipse(this.container.ellipse()); + } + + public line(): EngineLine { + return new SvgLine(this.container.line()); + } + + public rect(): EngineRect { + return new SvgRectOrEllipse(this.container.rect()); + } + + public text(): EngineText { + return new SvgText(this.container); + } + + public item(plugin: ShapePlugin): EngineItem { + return new SvgItem(this.container, this.renderer, plugin); + } + + public show(): void { + this.container.show(); + } + + public hide(): void { + this.container.hide(); + } + + public remove(): void { + this.container.remove(); + } + + public hitTest(x: number, y: number) { + const result: EngineObject[] = []; + + const point = new svg.Point(x, y); + for (const element of this.container.children()) { + if (!element.visible()) { + continue; + } + + const hitPoint = point.transform(element.matrix().inverseO()); + + if (element.inside(hitPoint.x, hitPoint.y)) { + result.push(getSource(element.node)); + } + } + + return result; + } +} \ No newline at end of file diff --git a/src/wireframes/engine/svg/line.ts b/src/wireframes/engine/svg/line.ts new file mode 100644 index 00000000..6a6cee3b --- /dev/null +++ b/src/wireframes/engine/svg/line.ts @@ -0,0 +1,42 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import * as svg from '@svgdotjs/svg.js'; +import { EngineLine } from './../interface'; +import { SvgObject } from './object'; +import { linkToSvg } from './utils'; + +export class SvgLine extends SvgObject implements EngineLine { + protected get root() { + return this.shape; + } + + constructor( + private readonly shape: svg.Line, + ) { + super(); + linkToSvg(this, this.shape); + } + + public color(value: string): void { + this.shape.stroke(value); + } + + public plot(args: { x1: number; y1: number; x2: number; y2: number; width: number }): void { + const { x1, y1, x2, y2, width } = args; + + this.shape.plot(x1, y1, x2, y2).stroke({ width }); + } + + public label(value?: string): string { + if (value) { + this.shape.id(value); + } + + return this.shape.id(); + } +} \ No newline at end of file diff --git a/src/wireframes/engine/svg/object.ts b/src/wireframes/engine/svg/object.ts new file mode 100644 index 00000000..48869950 --- /dev/null +++ b/src/wireframes/engine/svg/object.ts @@ -0,0 +1,46 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import * as svg from '@svgdotjs/svg.js'; +import { Types } from '@app/core'; +import { EngineObject } from './../interface'; + +export abstract class SvgObject implements EngineObject { + protected abstract get root(): svg.Element; + + public cursor(value: string | number): void { + if (Types.isNumber(value)) { + (this.root.node as any)['cursorAngle'] = value; + } else { + (this.root.node as any)['cursor'] = value; + } + } + + public show() { + this.root.show(); + } + + public hide() { + this.root.hide(); + } + + public disable() { + this.root.node.style.pointerEvents = 'none'; + } + + public remove() { + this.root.remove(); + } + + public label(value?: string): string { + if (value) { + this.root.id(value); + } + + return this.root.id(); + } +} \ No newline at end of file diff --git a/src/wireframes/engine/svg/rect-or-ellipse.ts b/src/wireframes/engine/svg/rect-or-ellipse.ts new file mode 100644 index 00000000..c69b3c62 --- /dev/null +++ b/src/wireframes/engine/svg/rect-or-ellipse.ts @@ -0,0 +1,40 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import * as svg from '@svgdotjs/svg.js'; +import { EngineRect } from '../interface'; +import { SvgObject } from './object'; +import { linkToSvg, SvgHelper } from './utils'; + +export class SvgRectOrEllipse extends SvgObject implements EngineRect { + protected get root() { + return this.shape; + } + + constructor( + private readonly shape: svg.Shape, + ) { + super(); + linkToSvg(this, this.shape); + } + + public strokeWidth(value: number): void { + this.shape.stroke({ width: value }); + } + + public strokeColor(value: string): void { + this.shape.stroke({ color: value }); + } + + public fill(value: string): void { + this.shape.fill(value); + } + + public plot(args: { x: number; y: number; w: number; h: number; rotation?: number; rx?: number; ry?: number }): void { + SvgHelper.transformBy(this.shape, args, false, true); + } +} \ No newline at end of file diff --git a/src/wireframes/shapes/utils/svg-renderer2.spec.ts b/src/wireframes/engine/svg/renderer.spec.ts similarity index 87% rename from src/wireframes/shapes/utils/svg-renderer2.spec.ts rename to src/wireframes/engine/svg/renderer.spec.ts index 2ce6a823..da19049b 100644 --- a/src/wireframes/shapes/utils/svg-renderer2.spec.ts +++ b/src/wireframes/engine/svg/renderer.spec.ts @@ -7,19 +7,19 @@ import * as svg from '@svgdotjs/svg.js'; import { Rect2 } from '@app/core/utils'; -import { SVGRenderer2 } from './svg-renderer2'; +import { SvgRenderer } from './renderer'; -describe('SVGRenderer2', () => { - let renderer: SVGRenderer2; +describe('SVGRenderer', () => { + const bounds = new Rect2(0, 0, 100, 100); + let renderer: SvgRenderer; let svgGroup: svg.G; let svgRoot: svg.Svg; - const bounds = new Rect2(0, 0, 100, 100); beforeEach(() => { svgRoot = svg.SVG().addTo(document.body); svgGroup = new svg.G().addTo(svgRoot); - renderer = new SVGRenderer2(); + renderer = new SvgRenderer(); renderer.setContainer(svgGroup); }); @@ -339,6 +339,16 @@ describe('SVGRenderer2', () => { expect((svgGroup.get(0).node.children[0] as HTMLDivElement).style.fontFamily).toEqual('Arial'); }); + it('should render text decoration', () => { + render(r => { + r.text({} as any, bounds, p => { + p.setText('Text').setTextDecoration('underline'); + }); + }); + + expect((svgGroup.get(0).node.children[0] as HTMLDivElement).style.textDecoration).toEqual('underline'); + }); + it('should render opacity', () => { render(r => { r.text({} as any, bounds, p => { @@ -366,7 +376,7 @@ describe('SVGRenderer2', () => { }); }); - expect((svgGroup.get(0).node.children[0]).textContent).toEqual('Text'); + expect(svgGroup.get(0).node.children[0].textContent).toEqual('Text'); }); it('should render text from shape', () => { @@ -376,11 +386,36 @@ describe('SVGRenderer2', () => { }); }); - expect((svgGroup.get(0).node.children[0]).textContent).toEqual('Text'); + expect(svgGroup.get(0).node.children[0].textContent).toEqual('Text'); + }); + + it('should render text as markdown', () => { + render(r => { + r.text({} as any, bounds, p => { + p.setText('**Text**', true); + }); + }); + + expect(svgGroup.get(0).node.children[0].innerHTML).toEqual('Text'); + }); + }); + + describe('Utils', () => { + it('should calculate text size', () => { + const size1 = renderer.getTextWidth('Hello World', 16, 'inherit'); + const size2 = renderer.getTextWidth('Hello World', 18, 'inherit'); + + expect(size2).toBeGreaterThan(size1); + }); + + it('should get outer bounds', () => { + const bounds = renderer.getOuterBounds(4, new Rect2(10, 20, 30, 40)); + + expect(bounds).toEqual({ x: 12, y: 22, w: 26, h: 36 }); }); }); - function render(action: (renderer: SVGRenderer2) => void) { + function render(action: (renderer: SvgRenderer) => void) { renderer.setContainer(svgGroup); action(renderer); renderer.cleanupAll(); diff --git a/src/wireframes/shapes/utils/svg-renderer2.ts b/src/wireframes/engine/svg/renderer.ts similarity index 82% rename from src/wireframes/shapes/utils/svg-renderer2.ts rename to src/wireframes/engine/svg/renderer.ts index 1d0ae420..ca8919d6 100644 --- a/src/wireframes/shapes/utils/svg-renderer2.ts +++ b/src/wireframes/engine/svg/renderer.ts @@ -10,13 +10,11 @@ import * as svg from '@svgdotjs/svg.js'; import { marked } from 'marked'; import { Rect } from 'react-measure'; -import { escapeHTML, Rect2, sizeInPx, SVGHelper, Types } from '@app/core/utils'; -import { RendererColor, RendererElement, RendererOpacity, RendererText, RendererWidth, Shape, ShapeFactory, ShapeFactoryFunc, ShapeProperties, ShapePropertiesFunc, TextConfig, TextDecoration } from '@app/wireframes/interface'; -import { AbstractRenderer2 } from './abstract-renderer'; +import { escapeHTML, Rect2, TextMeasurer, Types } from '@app/core/utils'; +import { RendererColor, RendererElement, RendererOpacity, RendererText, RendererWidth, Shape, ShapeProperties, ShapePropertiesFunc, ShapeRenderer, ShapeRendererFunc, TextConfig, TextDecoration } from '@app/wireframes/interface'; +import { SvgHelper } from './utils'; -export * from './abstract-renderer'; - -class Factory implements ShapeFactory { +export class SvgRenderer implements ShapeRenderer { private container: svg.G = null!; private containerIndex = 0; private clipping = false; @@ -26,6 +24,10 @@ class Factory implements ShapeFactory { return this.container; } + public getTextWidth(text: string, fontSize: number, fontFamily: string) { + return TextMeasurer.DEFAULT.getTextWidth(text, fontSize, fontFamily); + } + public setContainer(container: svg.G, index = 0, clipping = false) { this.clipping = clipping; this.container = container; @@ -70,7 +72,7 @@ class Factory implements ShapeFactory { return this.new('path', () => new svg.Path(), p => { p.setBackgroundColor('transparent'); p.setStrokeWidth(actualStroke); - p.setPath(SVGHelper.roundedRectangleLeft(actualBounds, radius)); + p.setPath(SvgHelper.roundedRectangleLeft(actualBounds, radius)); }, properties); } @@ -81,7 +83,7 @@ class Factory implements ShapeFactory { return this.new('path', () => new svg.Path(), p => { p.setBackgroundColor('transparent'); p.setStrokeWidth(actualStroke); - p.setPath(SVGHelper.roundedRectangleRight(actualBounds, radius)); + p.setPath(SvgHelper.roundedRectangleRight(actualBounds, radius)); }, properties); } @@ -92,7 +94,7 @@ class Factory implements ShapeFactory { return this.new('path', () => new svg.Path(), p => { p.setBackgroundColor('transparent'); p.setStrokeWidth(actualStroke); - p.setPath(SVGHelper.roundedRectangleTop(actualBounds, radius)); + p.setPath(SvgHelper.roundedRectangleTop(actualBounds, radius)); }, properties); } @@ -103,7 +105,7 @@ class Factory implements ShapeFactory { return this.new('path', () => new svg.Path(), p => { p.setBackgroundColor('transparent'); p.setStrokeWidth(actualStroke); - p.setPath(SVGHelper.roundedRectangleBottom(actualBounds, radius)); + p.setPath(SvgHelper.roundedRectangleBottom(actualBounds, radius)); }, properties); } @@ -118,7 +120,7 @@ class Factory implements ShapeFactory { } public text(config: RendererText, bounds: Rect2, properties?: ShapePropertiesFunc, allowMarkdown?: boolean) { - return this.new('foreignObject', () => SVGHelper.createText(), p => { + return this.new('foreignObject', () => SvgHelper.createText(), p => { p.setBackgroundColor('transparent'); p.setText(config?.text, allowMarkdown); p.setFontSize(config); @@ -130,7 +132,7 @@ class Factory implements ShapeFactory { } public textMultiline(config: RendererText, bounds: Rect2, properties?: ShapePropertiesFunc, allowMarkdown?: boolean) { - return this.new('foreignObject', () => SVGHelper.createText(), p => { + return this.new('foreignObject', () => SvgHelper.createText(), p => { p.setBackgroundColor('transparent'); p.setText(config?.text, allowMarkdown); p.setFontSize(config); @@ -150,7 +152,7 @@ class Factory implements ShapeFactory { }, properties); } - public group(items: ShapeFactoryFunc, clip?: ShapeFactoryFunc, properties?: ShapePropertiesFunc) { + public group(items: ShapeRendererFunc, clip?: ShapeRendererFunc, properties?: ShapePropertiesFunc) { return this.new('g', () => new svg.G(), (_, group) => { const clipping = this.clipping; const container = this.container; @@ -255,65 +257,6 @@ class Factory implements ShapeFactory { } } -export class SVGRenderer2 extends Factory implements AbstractRenderer2 { - private readonly measureDiv: HTMLDivElement; - - public static readonly INSTANCE = new SVGRenderer2(); - - public constructor() { - super(); - - 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 getLocalBounds(element: RendererElement): Rect2 { - const e = this.getElement(element); - - if (!e.visible()) { - return Rect2.EMPTY; - } - - const box: svg.Box = e.bbox(); - - return SVGHelper.box2Rect(box); - } - - public getBounds(element: RendererElement): Rect2 { - const e = this.getElement(element); - - if (!e.visible()) { - return Rect2.EMPTY; - } - - const box = e.bbox().transform(e.matrixify()); - - return SVGHelper.box2Rect(box); - } - - private getElement(element: RendererElement): svg.Element { - if (element.textElement) { - return element.textElement; - } else { - return element; - } - } - - 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; - } -} - type PropertySet = Partial<{ ['color']: any; ['fill']: any; @@ -361,38 +304,38 @@ const PROPERTIES: ReadonlyArray = [ class Properties implements ShapeProperties { private static readonly SETTERS: Record void> = { 'color': (value, element) => { - SVGHelper.fastSetAttribute(element.node, 'color', value); + SvgHelper.fastSetAttribute(element.node, 'color', value); }, 'fill': (value, element) => { - SVGHelper.setAttribute(element.node, 'fill', value); + SvgHelper.setAttribute(element.node, 'fill', value); }, 'opacity': (value, element) => { - SVGHelper.setAttribute(element.node, 'opacity', value); + SvgHelper.setAttribute(element.node, 'opacity', value); }, 'preserve-aspect-ratio': (value, element) => { - SVGHelper.setAttribute(element.node, 'preserveAspectRatio', value ? 'xMidYMid' : 'none'); + SvgHelper.setAttribute(element.node, 'preserveAspectRatio', value ? 'xMidYMid' : 'none'); }, 'stroke': (value, element) => { - SVGHelper.setAttribute(element.node, 'stroke', value); + SvgHelper.setAttribute(element.node, 'stroke', value); }, 'stroke-cap': (value, element) => { - SVGHelper.setAttribute(element.node, 'stroke-linecap', value); + SvgHelper.setAttribute(element.node, 'stroke-linecap', value); }, 'stroke-line-join': (value, element) => { - SVGHelper.setAttribute(element.node, 'stroke-linejoin', value); + SvgHelper.setAttribute(element.node, 'stroke-linejoin', value); }, 'stroke-width': (value, element) => { - SVGHelper.setAttribute(element.node, 'stroke-width', value); + SvgHelper.setAttribute(element.node, 'stroke-width', value); }, 'image': (value, element) => { - SVGHelper.setAttribute(element.node, 'href', value); + SvgHelper.setAttribute(element.node, 'href', value); }, 'path': (value, element) => { - SVGHelper.setAttribute(element.node, 'd', value); + SvgHelper.setAttribute(element.node, 'd', value); }, 'radius': (value, element) => { - SVGHelper.setAttribute(element.node, 'rx', value); - SVGHelper.setAttribute(element.node, 'ry', value); + SvgHelper.setAttribute(element.node, 'rx', value); + SvgHelper.setAttribute(element.node, 'ry', value); }, 'font-family': (value, element) => { const div = element.node.children[0] as HTMLDivElement; @@ -424,8 +367,16 @@ class Properties implements ShapeProperties { 'text': (value, element) => { const div = element.node.children[0] as HTMLDivElement; + if (div?.nodeName === 'DIV') { - const textOrHtml = escapeHTML(value); + const typed = value as { text: string; markdown?: boolean } | undefined; + + let textOrHtml = ''; + if (typed?.markdown) { + textOrHtml = marked.parseInline(typed.text) as string; + } else if (typed?.text) { + textOrHtml = escapeHTML(typed.text); + } if (textOrHtml.indexOf('&') >= 0 || textOrHtml.indexOf('<') >= 0) { div.innerHTML = textOrHtml; @@ -456,17 +407,13 @@ class Properties implements ShapeProperties { } }, 'transform': (value, element) => { - SVGHelper.transformByRect(element, value, false); + SvgHelper.transformByRect(element, value, false); }, }; - private element: svg.Element = null!; private propertiesNew: PropertySet = {}; private propertiesOld: PropertySet = {}; - - public get shape() { - return this.element; - } + private element: svg.Element = null!; public static readonly INSTANCE = new Properties(); @@ -565,6 +512,12 @@ class Properties implements ShapeProperties { return this; } + public setText(text: RendererText | string | null | undefined, markdown?: boolean): ShapeProperties { + this.propertiesNew['text'] = { text: getText(text), markdown }; + + return this; + } + public setStrokeStyle(cap: string, join: string): ShapeProperties { this.propertiesNew['stroke-cap'] = cap; this.propertiesNew['stroke-line-join'] = join; @@ -582,16 +535,6 @@ class Properties implements ShapeProperties { return this; } - public setText(text: RendererText | string | null | undefined, markdown?: boolean): ShapeProperties { - if (markdown) { - this.propertiesNew['markdown'] = getText(text); - } else { - this.propertiesNew['text'] = getText(text); - } - - return this; - } - public sync() { for (const key of PROPERTIES) { const value = this.propertiesNew[key]; @@ -613,25 +556,25 @@ function getBounds(bounds: Rect2, strokeWidth: number) { function getBackgroundColor(value: RendererColor | null | undefined) { if (isShape(value)) { - return SVGHelper.toColor(value.backgroundColor); + return SvgHelper.toColor(value.backgroundColor); } else { - return SVGHelper.toColor(value); + return SvgHelper.toColor(value); } } function getForegroundColor(value: RendererColor | null | undefined) { if (isShape(value)) { - return SVGHelper.toColor(value.foregroundColor); + return SvgHelper.toColor(value.foregroundColor); } else { - return SVGHelper.toColor(value); + return SvgHelper.toColor(value); } } function getStrokeColor(value: RendererColor | null | undefined) { if (isShape(value)) { - return SVGHelper.toColor(value.strokeColor); + return SvgHelper.toColor(value.strokeColor); } else { - return SVGHelper.toColor(value); + return SvgHelper.toColor(value); } } diff --git a/src/wireframes/shapes/utils/svg-renderer2.stories.tsx b/src/wireframes/engine/svg/svg-renderer2.stories.tsx similarity index 84% rename from src/wireframes/shapes/utils/svg-renderer2.stories.tsx rename to src/wireframes/engine/svg/svg-renderer2.stories.tsx index 3087ea72..57214c83 100644 --- a/src/wireframes/shapes/utils/svg-renderer2.stories.tsx +++ b/src/wireframes/engine/svg/svg-renderer2.stories.tsx @@ -5,13 +5,14 @@ * Copyright (c) Sebastian Stehle. All rights reserved. */ -import { ComponentMeta } from '@storybook/react'; +import { Meta } from '@storybook/react'; import * as svg from '@svgdotjs/svg.js'; import * as React from 'react'; -import { Color, Rect2, SVGHelper } from '@app/core'; -import { SVGRenderer2 } from './svg-renderer2'; +import { Color, Rect2 } from '@app/core'; +import { SvgRenderer } from './renderer'; +import { SvgHelper } from './utils'; -const RendererHelper = ({ render }: { render: (renderer: SVGRenderer2, width: number, color: string) => void }) => { +const RendererHelper = ({ render }: { render: (renderer: SvgRenderer, width: number, color: string) => void }) => { const [document, setDocument] = React.useState(); const innerRef = React.useRef(null); @@ -31,20 +32,20 @@ const RendererHelper = ({ render }: { render: (renderer: SVGRenderer2, width: nu const itemCount = 10; const itemHeight = 50; - document.viewbox(0, 0, itemCount * itemHeight, 100); + document.viewbox(0, 0, 100, itemCount * itemHeight); document.clear(); - SVGHelper.setSize(document, itemCount * itemHeight, 100); + SvgHelper.setSize(document, 100, itemCount * itemHeight); - const renderer2 = new SVGRenderer2(); + const renderer2 = new SvgRenderer(); for (let i = 0; i < itemCount; i++) { const group = document.group(); const color = Color.fromHsv((360 / itemCount) * i, 1, 0.75); - SVGHelper.setSize(group, itemHeight, 100); - SVGHelper.setPosition(group, 0.5, (i * itemHeight) + 0.5); + SvgHelper.setSize(group, 100, itemHeight); + SvgHelper.setPosition(group, 0.5, (i * itemHeight) + 0.5); renderer2.setContainer(group); render(renderer2, i, color.toString()); @@ -60,7 +61,7 @@ const RendererHelper = ({ render }: { render: (renderer: SVGRenderer2, width: nu export default { component: RendererHelper, -} as ComponentMeta; +} as Meta; export const Rect = () => { return ( @@ -101,8 +102,6 @@ export const RoundedRectRight = () => { ); }; - - export const RoundedRectTop = () => { return ( 0 && h > 0) { if (element.node.nodeName === 'foreignObject') { const text = element.node.children[0]; @@ -152,8 +142,6 @@ export module SVGHelper { } if (element.node.nodeName === 'ellipse') { - fastSetAttribute(element.node, 'cx', w * 0.5); - fastSetAttribute(element.node, 'cy', h * 0.5); fastSetAttribute(element.node, 'rx', w * 0.5); fastSetAttribute(element.node, 'ry', h * 0.5); } else { @@ -161,15 +149,28 @@ export module SVGHelper { } } - return element; - } + let matrix = new svg.Matrix(); - export function point2Vec(point: svg.Point): Vec2 { - return new Vec2(point.x, point.y); - } + if (r !== 0) { + matrix.rotateO(r, t.rx || (x + 0.5 * w), t.ry || (y + 0.5 * h)); + } + + if (move) { + element.matrix(matrix); - export function box2Rect(box: svg.Box): Rect2 { - return new Rect2(box.x, box.y, box.w, box.h); + if (x !== 0 || y !== 0) { + element.move(x, y); + } + } else { + if (x !== 0 || y !== 0) { + // Use the alternative methods with O to not create a new matrix. + matrix = matrix.multiplyO(new svg.Matrix().translateO(x, y)); + } + + element.matrix(matrix); + } + + return element; } export function setPosition(element: svg.Element, x: number, y: number) { diff --git a/src/wireframes/interface/index.ts b/src/wireframes/interface/index.ts index 8414f205..c77fde91 100644 --- a/src/wireframes/interface/index.ts +++ b/src/wireframes/interface/index.ts @@ -8,30 +8,24 @@ import { Color, LoadedImage, Rect2, Vec2 } from '@app/core/utils'; export { Color, Rect2, Vec2 } from '@app/core/utils'; - export type Appearance = { [key: string]: any }; -export type CreatedShape = { renderer: string; size?: { x: number; y: number }; appearance?: Appearance }; -export type ShapeSourceIcon = { type: 'Icon'; text: string; fontFamily: string }; -export type ShapeSourceImage = { type: 'Image'; image: LoadedImage }; -export type ShapeSourceText = { type: 'Text'; text: string }; -export type ShapeSourceUrl = { type: 'Url'; url: string }; -export type ShapeSource = ShapeSourceIcon | ShapeSourceImage | ShapeSourceText | ShapeSourceUrl; export type Configurable = any; +export type CreatedShape = { renderer: string; size?: { x: number; y: number }; appearance?: Appearance }; +export type RenderContext = { shape: Shape; renderer2: ShapeRenderer; rect: Rect2 }; export type RendererColor = string | number | Color | Shape; export type RendererElement = any; export type RendererOpacity = number | Shape; export type RendererText = TextConfig | Shape; export type RendererWidth = number | Shape; +export type ShapeSource = ShapeSourceIcon | ShapeSourceImage | ShapeSourceText | ShapeSourceUrl; +export type ShapeSourceIcon = { type: 'Icon'; text: string; fontFamily: string }; +export type ShapeSourceImage = { type: 'Image'; image: LoadedImage }; +export type ShapeSourceText = { type: 'Text'; text: string }; +export type ShapeSourceUrl = { type: 'Url'; url: string }; export type Size = { x: number; y: number }; export type TextConfig = { text: string; fontSize?: number; fontFamily?: string; alignment?: string }; export type TextDecoration = 'underline' | 'none'; -export interface RenderContext { - readonly shape: Shape; - readonly renderer2: ShapeRenderer2; - readonly rect: Rect2; -} - export interface ShapePlugin { identifier(): string; @@ -55,8 +49,6 @@ export interface ShapePlugin { } export interface ShapeProperties { - readonly shape: any; - setForegroundColor(color: RendererColor): ShapeProperties; setBackgroundColor(color: RendererColor): ShapeProperties; @@ -69,15 +61,15 @@ export interface ShapeProperties { setOpacity(opacity: RendererOpacity): ShapeProperties; - setText(text: RendererText | string): ShapeProperties; + setText(text: RendererText | string, markdown?: boolean): ShapeProperties; setTextDecoration(decoration: TextDecoration): ShapeProperties; } export type ShapePropertiesFunc = (properties: ShapeProperties) => void; -export type ShapeFactoryFunc = (factory: ShapeFactory) => void; +export type ShapeRendererFunc = (factory: ShapeRenderer) => void; -export interface ShapeFactory { +export interface ShapeRenderer { ellipse(strokeWidth: RendererWidth, bounds: Rect2, properties?: ShapePropertiesFunc): RendererElement; rectangle(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc): RendererElement; @@ -100,15 +92,9 @@ export interface ShapeFactory { getOuterBounds(strokeWidth: RendererWidth, bounds: Rect2): Rect2; - group(items: ShapeFactoryFunc, clip?: ShapeFactoryFunc, properties?: ShapePropertiesFunc): RendererElement; -} - -export interface ShapeRenderer2 extends ShapeFactory { - getBounds(element: RendererElement): Rect2; - - getLocalBounds(element: RendererElement): Rect2; - getTextWidth(text: string, fontSize: number, fontFamily: string): number; + + group(items: ShapeRendererFunc, clip?: ShapeRendererFunc, properties?: ShapePropertiesFunc): RendererElement; } export interface Shape { diff --git a/src/wireframes/model/actions/appearance.spec.ts b/src/wireframes/model/actions/appearance.spec.ts index ae6ded48..86a8adf3 100644 --- a/src/wireframes/model/actions/appearance.spec.ts +++ b/src/wireframes/model/actions/appearance.spec.ts @@ -9,9 +9,8 @@ import { Color, Rotation, Vec2 } from '@app/core/utils'; import { DefaultAppearance } from '@app/wireframes/interface'; -import { buildAppearance, changeColors, changeItemsAppearance, createClassReducer, Diagram, DiagramItem, EditorState, RendererService, Transform, transformItems } from '@app/wireframes/model'; +import { buildAppearance, changeColors, changeItemsAppearance, createClassReducer, Diagram, DiagramItem, EditorState, PluginRegistry, Transform, transformItems } from '@app/wireframes/model'; import { Button } from '@app/wireframes/shapes/neutral/button'; -import { AbstractControl } from '@app/wireframes/shapes/utils/abstract-control'; describe('AppearanceReducer', () => { const shape1 = DiagramItem.createShape({ @@ -49,7 +48,7 @@ describe('AppearanceReducer', () => { EditorState.create() .addDiagram(diagram); - RendererService.addRenderer(new AbstractControl(new Button())); + PluginRegistry.addPlugin(new Button()); const reducer = createClassReducer(state, builder => buildAppearance(builder)); diff --git a/src/wireframes/model/actions/appearance.ts b/src/wireframes/model/actions/appearance.ts index 975ca4d5..6fdcafae 100644 --- a/src/wireframes/model/actions/appearance.ts +++ b/src/wireframes/model/actions/appearance.ts @@ -7,7 +7,7 @@ import { ActionReducerMapBuilder, createAction } from '@reduxjs/toolkit'; import { Color, Types } from '@app/core/utils'; -import { EditorState, RendererService, Transform } from './../internal'; +import { EditorState, PluginRegistry, Transform } from './../internal'; import { createItemsAction, DiagramRef, ItemsRef } from './utils'; export const changeColors = @@ -62,13 +62,13 @@ export function buildAppearance(builder: ActionReducerMapBuilder) { const { key, value } = appearance; return diagram.updateItems(itemIds, item => { - const rendererInstance = RendererService.get(item.renderer); - - if (!rendererInstance) { + const plugin = PluginRegistry.get(item.renderer)?.plugin; + + if (!plugin) { throw new Error(`Cannot find renderer for ${item.renderer}.`); } - if (force || !Types.isUndefined(rendererInstance.defaultAppearance()[key])) { + if (force || !Types.isUndefined(plugin.defaultAppearance()[key])) { return item.setAppearance(key, value); } diff --git a/src/wireframes/model/actions/items.spec.ts b/src/wireframes/model/actions/items.spec.ts index dabcd51e..23291b01 100644 --- a/src/wireframes/model/actions/items.spec.ts +++ b/src/wireframes/model/actions/items.spec.ts @@ -8,11 +8,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Vec2 } from '@app/core/utils'; -import { addShape, buildItems, calculateSelection, createClassReducer, Diagram, DiagramItem, DiagramItemSet, EditorState, lockItems, pasteItems, removeItems, renameItems, RendererService, selectItems, Serializer, unlockItems } from '@app/wireframes/model'; +import { addShape, buildItems, calculateSelection, createClassReducer, Diagram, DiagramItem, DiagramItemSet, EditorState, lockItems, pasteItems, PluginRegistry, removeItems, renameItems, selectItems, Serializer, unlockItems } from '@app/wireframes/model'; import { Button } from '@app/wireframes/shapes/neutral/button'; import { Icon } from '@app/wireframes/shapes/shared/icon'; import { Raster } from '@app/wireframes/shapes/shared/raster'; -import { AbstractControl } from '@app/wireframes/shapes/utils/abstract-control'; describe('ItemsReducer', () => { const groupId = 'group-1'; @@ -27,9 +26,9 @@ describe('ItemsReducer', () => { .addShape(shape3); diagram = diagram.group(groupId, [shape1.id, shape2.id]); - RendererService.addRenderer(new AbstractControl(new Icon())); - RendererService.addRenderer(new AbstractControl(new Button())); - RendererService.addRenderer(new AbstractControl(new Raster())); + PluginRegistry.addPlugin(new Icon()); + PluginRegistry.addPlugin(new Button()); + PluginRegistry.addPlugin(new Raster()); const state = EditorState.create() diff --git a/src/wireframes/model/actions/items.ts b/src/wireframes/model/actions/items.ts index d263c302..55d494e6 100644 --- a/src/wireframes/model/actions/items.ts +++ b/src/wireframes/model/actions/items.ts @@ -10,7 +10,7 @@ import { ActionReducerMapBuilder, createAction } from '@reduxjs/toolkit'; import { MathHelper, Rotation, Vec2 } from '@app/core/utils'; import { Appearance } from '@app/wireframes/interface'; -import { Diagram, DiagramItem, DiagramItemSet, EditorState, RendererService, Serializer, Transform } from './../internal'; +import { Diagram, DiagramItem, DiagramItemSet, EditorState, PluginRegistry, Serializer, Transform } from './../internal'; import { createDiagramAction, createItemsAction, DiagramRef, ItemsRef } from './utils'; export const addShape = @@ -121,13 +121,13 @@ export function buildItems(builder: ActionReducerMapBuilder) { const { diagramId, appearance, id, position, renderer, size } = action.payload; return state.updateDiagram(diagramId, diagram => { - const rendererInstance = RendererService.get(renderer); + const registration = PluginRegistry.get(renderer); - if (!rendererInstance) { + if (!registration) { throw new Error(`Cannot find renderer for ${renderer}.`); } - const { size: defaultSize, appearance: defaultAppearance, ...other } = rendererInstance.createDefaultShape(); + const { size: defaultSize, appearance: defaults, ...other } = registration.createDefaultShape(); const initialSize = size || defaultSize; const initialProps = { @@ -141,7 +141,7 @@ export function buildItems(builder: ActionReducerMapBuilder) { initialSize.x, initialSize.y), Rotation.ZERO), - appearance: { ...defaultAppearance || {}, ...appearance }, + appearance: { ...defaults || {}, ...appearance }, }; const shape = DiagramItem.createShape(initialProps); diff --git a/src/wireframes/model/actions/loading.spec.ts b/src/wireframes/model/actions/loading.spec.ts index b1445e47..3d1a3b3e 100644 --- a/src/wireframes/model/actions/loading.spec.ts +++ b/src/wireframes/model/actions/loading.spec.ts @@ -9,10 +9,9 @@ import { diff } from 'deep-object-diff'; import { Types } from '@app/core'; -import { EditorState, loadDiagramInternal, RendererService, selectDiagram, selectItems, UndoableState } from '@app/wireframes/model'; +import { EditorState, loadDiagramInternal, PluginRegistry, selectDiagram, selectItems, UndoableState } from '@app/wireframes/model'; import * as Reducers from '@app/wireframes/model/actions'; import { Button } from '@app/wireframes/shapes/neutral/button'; -import { AbstractControl } from '@app/wireframes/shapes/utils/abstract-control'; import v1 from './diagram_v1.json?raw'; import v2 from './diagram_v2.json?raw'; import v3 from './diagram_v3.json?raw'; @@ -20,7 +19,7 @@ import v3 from './diagram_v3.json?raw'; describe('LoadingReducer', () => { const editorState = EditorState.create(); - RendererService.addRenderer(new AbstractControl(new Button())); + PluginRegistry.addPlugin(new Button()); const editorReducer = Reducers.createClassReducer(editorState, builder => { Reducers.buildAlignment(builder); diff --git a/src/wireframes/model/assets-state.ts b/src/wireframes/model/assets-state.ts index eb39e606..d9e64829 100644 --- a/src/wireframes/model/assets-state.ts +++ b/src/wireframes/model/assets-state.ts @@ -5,10 +5,10 @@ * Copyright (c) Sebastian Stehle. All rights reserved. */ +import { ShapePlugin } from '@app/wireframes/interface'; import { ICONS_FONT_AWESOME } from './../../icons/font_awesome_unified'; import { ICONS_MATERIAL_DESIGN } from './../../icons/material_icons_unified'; -import { ShapePlugin } from './../interface'; -import { RendererService } from './renderer.service'; +import { PluginRegistry } from './registry'; export interface AssetInfo { // The name of the asset. @@ -56,10 +56,10 @@ export interface AssetsState { export const createInitialAssetsState: () => AssetsState = () => { const allShapes = - RendererService.all().filter(x => x[1].plugin().showInGallery?.() !== false) - .map(([name, renderer]) => { + PluginRegistry.all().filter(x => x[1].plugin.showInGallery?.() !== false) + .map(([name, registration]) => { return { - plugin: renderer.plugin(), + plugin: registration.plugin, displayName: name, displaySearch: name, name, diff --git a/src/wireframes/model/constraints.spec.ts b/src/wireframes/model/constraints.spec.ts index 214cebe4..f1ecbb4d 100644 --- a/src/wireframes/model/constraints.spec.ts +++ b/src/wireframes/model/constraints.spec.ts @@ -5,9 +5,9 @@ * Copyright (c) Sebastian Stehle. All rights reserved. */ -import { Vec2 } from '@app/core/utils'; +import { TextMeasurer, Vec2 } from '@app/core/utils'; import { DefaultAppearance } from '@app/wireframes/interface'; -import { DiagramItem, MinSizeConstraint, SizeConstraint, TextHeightConstraint } from '@app/wireframes/model'; +import { DiagramItem, MinSizeConstraint, SizeConstraint, TextHeightConstraint, TextSizeConstraint } from '@app/wireframes/model'; const shape = DiagramItem.createShape({ id: '1', @@ -72,3 +72,159 @@ describe('SizeConstraint', () => { expect(constraint.calculateSizeY()).toBeTruthy(); }); }); + + +describe('TextSizeConstraint', () => { + let measured = 0; + + const measurer: TextMeasurer = { + getTextWidth(text, fontSize) { + measured++; + return text.length * fontSize * 1.5; + }, + }; + + beforeEach(() => { + measured = 0; + }); + + it('should only calculate width when width cannot be resized', () => { + const constraint1 = new TextSizeConstraint(measurer, 5, 5, 1.2, true, 10); + const constraint2 = new TextSizeConstraint(measurer, 5, 5, 1.2, false, 10); + + expect(constraint1.calculateSizeX()).toBeFalsy(); + expect(constraint1.calculateSizeY()).toBeTruthy(); + + expect(constraint2.calculateSizeX()).toBeTruthy(); + expect(constraint2.calculateSizeY()).toBeTruthy(); + }); + + it('should calculate size from measurer without padding', () => { + const constraint2 = new TextSizeConstraint(measurer, 0, 0, 1.5, true, 10); + + const newShape: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + const size = constraint2.updateSize(newShape, Vec2.ZERO, null!); + + expect(size.x).toBe(120); + expect(size.y).toBe(24); + }); + + it('should calculate size from measurer with padding', () => { + const constraint2 = new TextSizeConstraint(measurer, 3, 4, 1.5, true, 10); + + const newShape: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + const size = constraint2.updateSize(newShape, Vec2.ZERO); + + expect(size.x).toBe(126); + expect(size.y).toBe(32); + }); + + it('should calculate size from measurer min width', () => { + const constraint2 = new TextSizeConstraint(measurer, 3, 4, 1.5, true, 200); + + const newShape: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + const size = constraint2.updateSize(newShape, Vec2.ZERO); + + expect(size.x).toBe(200); + expect(size.y).toBe(32); + }); + + it('should cache font size', () => { + const constraint2 = new TextSizeConstraint(measurer, 3, 4, 1.5, true, 200); + + const shape1: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + const shape2: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + constraint2.updateSize(shape1, Vec2.ZERO); + constraint2.updateSize(shape2, Vec2.ZERO, shape1); + + expect(measured).toBe(1); + }); + + it('should recompute font width if text changed', () => { + const constraint2 = new TextSizeConstraint(measurer, 3, 4, 1.5, true, 200); + + const shape1: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + const shape2: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'World', + } as any; + + constraint2.updateSize(shape1, Vec2.ZERO); + constraint2.updateSize(shape2, Vec2.ZERO, shape1); + + expect(measured).toBe(2); + }); + + it('should recompute font width if font family changed', () => { + const constraint2 = new TextSizeConstraint(measurer, 3, 4, 1.5, true, 200); + + const shape1: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + const shape2: DiagramItem = { + fontSize: 16, + fontFamily: 'Aria', + text: 'Hello', + } as any; + + constraint2.updateSize(shape1, Vec2.ZERO); + constraint2.updateSize(shape2, Vec2.ZERO, shape1); + + expect(measured).toBe(2); + }); + + it('should recompute font width if font size changed', () => { + const constraint2 = new TextSizeConstraint(measurer, 3, 4, 1.5, true, 200); + + const shape1: DiagramItem = { + fontSize: 16, + fontFamily: 'monospace', + text: 'Hello', + } as any; + + const shape2: DiagramItem = { + fontSize: 18, + fontFamily: 'Aria', + text: 'Hello', + } as any; + + constraint2.updateSize(shape1, Vec2.ZERO); + constraint2.updateSize(shape2, Vec2.ZERO, shape1); + + expect(measured).toBe(2); + }); +}); diff --git a/src/wireframes/model/constraints.ts b/src/wireframes/model/constraints.ts index f4a1a335..d36155ca 100644 --- a/src/wireframes/model/constraints.ts +++ b/src/wireframes/model/constraints.ts @@ -5,8 +5,9 @@ * Copyright (c) Sebastian Stehle. All rights reserved. */ -import { MathHelper, Vec2 } from '@app/core/utils'; +import { MathHelper, TextMeasurer, Vec2 } from '@app/core/utils'; import { Shape } from '@app/wireframes/interface'; +import { DiagramItem } from './diagram-item'; export interface Constraint { updateSize(shape: Shape, size: Vec2, prev?: Shape): Vec2; @@ -89,3 +90,58 @@ export class TextHeightConstraint implements Constraint { return true; } } + +export class TextSizeConstraint implements Constraint { + constructor( + private readonly measurer: TextMeasurer, + private readonly paddingX = 0, + private readonly paddingY = 0, + private readonly lineHeight = 1.2, + private readonly resizeWidth = false, + private readonly minWidth = 0, + ) { } + + public updateSize(shape: Shape, size: Vec2, prev?: DiagramItem): Vec2 { + const fontSize = shape.fontSize; + const fontFamily = shape.fontFamily; + + let finalWidth = size.x; + + const text = shape.text; + + let prevText = ''; + let prevFontSize = 0; + let prevFontFamily = ''; + + if (prev) { + prevText = prev.text; + + prevFontSize = prev.fontSize; + prevFontFamily = prev.fontFamily; + } + + if (prevText !== text || prevFontSize !== fontSize || prevFontFamily !== fontFamily) { + let textWidth = this.measurer.getTextWidth(text, fontSize, fontFamily); + + if (textWidth) { + textWidth += 2 * this.paddingX; + + if (finalWidth < textWidth || !this.resizeWidth) { + finalWidth = textWidth; + } + + finalWidth = Math.max(this.minWidth, finalWidth); + } + } + + return new Vec2(finalWidth, fontSize * this.lineHeight + this.paddingY * 2).roundToMultipleOfTwo(); + } + + public calculateSizeX(): boolean { + return !this.resizeWidth; + } + + public calculateSizeY(): boolean { + return true; + } +} diff --git a/src/wireframes/model/diagram-item-set.spec.ts b/src/wireframes/model/diagram-item-set.spec.ts index 40a9eabb..2bcbf5b2 100644 --- a/src/wireframes/model/diagram-item-set.spec.ts +++ b/src/wireframes/model/diagram-item-set.spec.ts @@ -73,10 +73,20 @@ describe('Diagram Item Set', () => { it('should calculate editable once', () => { const set = DiagramItemSet.createFromDiagram([root1, root2, groupId], diagram); - const editable1 = set.deepEditableItems; - const editable2 = set.deepEditableItems; + const editable1 = set.editableItems; + const editable2 = set.editableItems; expect(editable1.map(x => x.id)).toEqual([root1.id, groupId, child1.id, child2.id]); expect(editable1).toBe(editable2); }); + + it('should calculate editable ids once', () => { + const set = DiagramItemSet.createFromDiagram([root1, root2, groupId], diagram); + + const editable1 = set.editableIds; + const editable2 = set.editableIds; + + expect(editable1).toEqual(new Set([root1.id, groupId, child1.id, child2.id])); + expect(editable1).toBe(editable2); + }); }); \ No newline at end of file diff --git a/src/wireframes/model/diagram-item-set.ts b/src/wireframes/model/diagram-item-set.ts index 825c4b2f..1344cefc 100644 --- a/src/wireframes/model/diagram-item-set.ts +++ b/src/wireframes/model/diagram-item-set.ts @@ -11,7 +11,8 @@ import { DiagramItem } from './diagram-item'; export class DiagramItemSet { private cachedSelectedItems?: ReadonlyArray; - private cachedDeepEditableItems?: ReadonlyArray; + private cachedEditableItems?: ReadonlyArray; + private cachedEditableId?: Set; public static EMPTY = new DiagramItemSet(new Map(), new Map()); @@ -22,8 +23,12 @@ export class DiagramItemSet { return this.cachedSelectedItems ||= Array.from(this.selection.values()).filter(x => !x.isLocked); } - public get deepEditableItems() { - return this.cachedDeepEditableItems ||= Array.from(this.nested.values()).filter(x => !this.selection.has(x.id) || !x.isLocked); + public get editableItems() { + return this.cachedEditableItems ||= Array.from(this.nested.values()).filter(x => !this.selection.has(x.id) || !x.isLocked); + } + + public get editableIds() { + return this.cachedEditableId ||= new Set(this.editableItems.map(x => x.id)); } constructor( diff --git a/src/wireframes/model/factories.spec.ts b/src/wireframes/model/factories.spec.ts new file mode 100644 index 00000000..4ecfd0f9 --- /dev/null +++ b/src/wireframes/model/factories.spec.ts @@ -0,0 +1,84 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import { ColorConfigurable, NumberConfigurable, SelectionConfigurable, SliderConfigurable, TextConfigurable, ToggleConfigurable } from './configurables'; +import { MinSizeConstraint, SizeConstraint, TextHeightConstraint, TextSizeConstraint } from './constraints'; +import { DefaultConfigurableFactory, DefaultConstraintFactory } from './factories'; + +describe('DefaultConstraintFactory', () => { + const factory = DefaultConstraintFactory.INSTANCE; + + it('should create size constraint', () => { + const constraint = factory.size(); + + expect(constraint).toBeInstanceOf(SizeConstraint); + }); + + it('should create min size constraint', () => { + const constraint = factory.minSize(); + + expect(constraint).toBeInstanceOf(MinSizeConstraint); + }); + + it('should create text height constraint', () => { + const constraint = factory.textHeight(50); + + expect(constraint).toBeInstanceOf(TextHeightConstraint); + }); + + it('should create text size constraint', () => { + const constraint = factory.textSize(50); + + expect(constraint).toBeInstanceOf(TextSizeConstraint); + }); +}); + +describe('DefaultConfigurableFactory', () => { + const factory = DefaultConfigurableFactory.INSTANCE; + + it('should create selection configurable', () => { + const constraint = factory.selection('my-name', 'my-label', ['A', 'B', 'C']); + + expect(constraint).toBeInstanceOf(SelectionConfigurable); + expect(constraint).toEqual({ name: 'my-name', label: 'my-label', options: ['A', 'B', 'C'] }); + }); + + it('should create slider configurable', () => { + const constraint = factory.slider('my-name', 'my-label', 50, 100); + + expect(constraint).toBeInstanceOf(SliderConfigurable); + expect(constraint).toEqual({ name: 'my-name', label: 'my-label', min: 50, max: 100 }); + }); + + it('should create number configurable', () => { + const constraint = factory.number('my-name', 'my-label', 50, 100); + + expect(constraint).toBeInstanceOf(NumberConfigurable); + expect(constraint).toEqual({ name: 'my-name', label: 'my-label', min: 50, max: 100 }); + }); + + it('should create color configurable', () => { + const constraint = factory.color('my-name', 'my-label'); + + expect(constraint).toBeInstanceOf(ColorConfigurable); + expect(constraint).toEqual({ name: 'my-name', label: 'my-label' }); + }); + + it('should create text configurable', () => { + const constraint = factory.text('my-name', 'my-label'); + + expect(constraint).toBeInstanceOf(TextConfigurable); + expect(constraint).toEqual({ name: 'my-name', label: 'my-label' }); + }); + + it('should create toggle configurable', () => { + const constraint = factory.toggle('my-name', 'my-label'); + + expect(constraint).toBeInstanceOf(ToggleConfigurable); + expect(constraint).toEqual({ name: 'my-name', label: 'my-label' }); + }); +}); \ No newline at end of file diff --git a/src/wireframes/model/factories.ts b/src/wireframes/model/factories.ts new file mode 100644 index 00000000..44a96cf8 --- /dev/null +++ b/src/wireframes/model/factories.ts @@ -0,0 +1,59 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import { TextMeasurer } from '@app/core'; +import { ConfigurableFactory, ConstraintFactory } from '@app/wireframes/interface'; +import { ColorConfigurable, NumberConfigurable, SelectionConfigurable, SliderConfigurable, TextConfigurable, ToggleConfigurable } from './configurables'; +import { Constraint, MinSizeConstraint, SizeConstraint, TextHeightConstraint, TextSizeConstraint } from './constraints'; + +export class DefaultConstraintFactory implements ConstraintFactory { + public static readonly INSTANCE = new DefaultConstraintFactory(); + + public size(width?: number, height?: number): any { + return new SizeConstraint(width, height); + } + + public minSize(): any { + return new MinSizeConstraint(); + } + + public textHeight(padding: number): any { + return new TextHeightConstraint(padding); + } + + public textSize(paddingX?: number, paddingY?: number, lineHeight?: number, resizeWidth?: false, minWidth?: number): Constraint { + return new TextSizeConstraint(TextMeasurer.DEFAULT, paddingX, paddingY, lineHeight, resizeWidth, minWidth); + } +} + +export class DefaultConfigurableFactory implements ConfigurableFactory { + public static readonly INSTANCE = new DefaultConfigurableFactory(); + + public selection(name: string, label: string, options: string[]) { + return new SelectionConfigurable(name, label, options); + } + + public slider(name: string, label: string, min: number, max: number) { + return new SliderConfigurable(name, label, min, max); + } + + public number(name: string, label: string, min: number, max: number) { + return new NumberConfigurable(name, label, min, max); + } + + public color(name: string, label: string) { + return new ColorConfigurable(name, label); + } + + public text(name: string, label: string) { + return new TextConfigurable(name, label); + } + + public toggle(name: string, label: string) { + return new ToggleConfigurable(name, label); + } +} \ No newline at end of file diff --git a/src/wireframes/model/internal.ts b/src/wireframes/model/internal.ts index c5b61f22..0359d113 100644 --- a/src/wireframes/model/internal.ts +++ b/src/wireframes/model/internal.ts @@ -8,13 +8,13 @@ export * from './assets-state'; export * from './configurables'; export * from './constraints'; -export * from './diagram'; -export * from './diagram-item'; export * from './diagram-item-set'; +export * from './diagram-item'; +export * from './diagram'; export * from './editor-state'; +export * from './factories'; export * from './loading-state'; -export * from './renderer'; -export * from './renderer.service'; +export * from './registry'; export * from './serializer'; export * from './snap-manager'; export * from './transform'; diff --git a/src/wireframes/model/registry.spec.ts b/src/wireframes/model/registry.spec.ts new file mode 100644 index 00000000..31e30780 --- /dev/null +++ b/src/wireframes/model/registry.spec.ts @@ -0,0 +1,20 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import { PluginRegistry } from '@app/wireframes/model'; + +describe('PluginRegistry', () => { + it('should register renderer with identifier', () => { + const plugin = { + identifier: () => 'MyPlugin', + } as any; + + PluginRegistry.addPlugin(plugin); + + expect(PluginRegistry.get('MyPlugin')?.plugin).toBe(plugin); + }); +}); diff --git a/src/wireframes/model/registry.ts b/src/wireframes/model/registry.ts new file mode 100644 index 00000000..72affdcc --- /dev/null +++ b/src/wireframes/model/registry.ts @@ -0,0 +1,66 @@ +/* + * mydraft.cc + * + * @license + * Copyright (c) Sebastian Stehle. All rights reserved. +*/ + +import { CreatedShape, ShapePlugin, ShapeSource, Size } from '@app/wireframes/interface'; +import { InitialShapeProps } from './diagram-item'; +import { DefaultConfigurableFactory, DefaultConstraintFactory } from './factories'; + +type DefaultProps = Omit, 'id'> & { size: Size }; + +export class Registration { + constructor( + public readonly plugin: ShapePlugin, + ) { + } + + public createDefaultShape(): DefaultProps { + const appearance = this.plugin.defaultAppearance(); + const constraint = this.plugin.constraint?.(DefaultConstraintFactory.INSTANCE); + const configurables = this.plugin.configurables?.(DefaultConfigurableFactory.INSTANCE); + const renderer = this.plugin.identifier(); + const size = this.plugin.defaultSize(); + + return { renderer, size, appearance, configurables, constraint }; + } +} + +export module PluginRegistry { + const REGISTRATIONS: { [id: string]: Registration } = {}; + + export function all() { + return Object.entries(REGISTRATIONS); + } + + export function get(id: string): Registration | undefined { + return REGISTRATIONS[id]; + } + + export function addPlugin(plugin: ShapePlugin) { + REGISTRATIONS[plugin.identifier()] = new Registration(plugin); + } + + export function createShapes(sources: ReadonlyArray): CreatedShape[] { + const result: CreatedShape[] = []; + + for (const source of sources) { + for (const [, renderer] of all()) { + const plugin = renderer.plugin; + + if (plugin.create) { + const shape = plugin.create(source); + + if (shape) { + result.push(shape); + break; + } + } + } + } + + return result; + } +} diff --git a/src/wireframes/model/renderer.service.spec.ts b/src/wireframes/model/renderer.service.spec.ts deleted file mode 100644 index 02697b65..00000000 --- a/src/wireframes/model/renderer.service.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * mydraft.cc - * - * @license - * Copyright (c) Sebastian Stehle. All rights reserved. -*/ - -import { Configurable, Renderer, RendererService } from '@app/wireframes/model'; - -class MockupRenderer implements Renderer { - public defaultAppearance() { - return {}; - } - - public plugin() { - return null!; - } - - public identifier(): string { - return 'identifier'; - } - - public showInGallery(): boolean { - return false; - } - - public createDefaultShape(): any { - return null; - } - - public createProperties(): Configurable[] { - return []; - } - - public setContext() { - return this; - } - - public render(): any { - return null; - } -} - -describe('RendererService', () => { - it('should register renderer with identifier', () => { - const renderer = new MockupRenderer(); - - RendererService.addRenderer(renderer); - - expect(RendererService.get('identifier')).toBe(renderer); - }); -}); diff --git a/src/wireframes/model/renderer.service.ts b/src/wireframes/model/renderer.service.ts deleted file mode 100644 index aa435831..00000000 --- a/src/wireframes/model/renderer.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * mydraft.cc - * - * @license - * Copyright (c) Sebastian Stehle. All rights reserved. -*/ - -import { CreatedShape, ShapeSource } from './../interface'; -import { Renderer } from './renderer'; - -export module RendererService { - const REGISTERED_RENDERER: { [id: string]: Renderer } = {}; - - export function all() { - return Object.entries(REGISTERED_RENDERER); - } - - export function get(id: string): Renderer | undefined { - return REGISTERED_RENDERER[id]; - } - - export function addRenderer(renderer: Renderer) { - REGISTERED_RENDERER[renderer.identifier()] = renderer; - } - - export function createShapes(sources: ReadonlyArray): CreatedShape[] { - const result: CreatedShape[] = []; - - for (const source of sources) { - for (const [, renderer] of all()) { - const plugin = renderer.plugin(); - - if (plugin.create) { - const shape = plugin.create(source); - - if (shape) { - result.push(shape); - break; - } - } - } - } - - return result; - } -} diff --git a/src/wireframes/model/renderer.ts b/src/wireframes/model/renderer.ts deleted file mode 100644 index 00925e09..00000000 --- a/src/wireframes/model/renderer.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * mydraft.cc - * - * @license - * Copyright (c) Sebastian Stehle. All rights reserved. -*/ - -import { ShapePlugin, Size } from './../interface'; -import { DiagramItem, InitialShapeProps } from './diagram-item'; - -type DefaultProps = Omit, 'id'> & { size: Size }; - -export interface Renderer { - identifier(): string; - - plugin(): ShapePlugin; - - defaultAppearance(): { [key: string]: any }; - - createDefaultShape(): DefaultProps; - - setContext(context: any): Renderer; - - render(shape: DiagramItem, existing: any, options?: { debug?: boolean; noOpacity?: boolean; noTransform?: boolean }): any; -} diff --git a/src/wireframes/model/serializer.spec.ts b/src/wireframes/model/serializer.spec.ts index 9d357c85..942a2ca9 100644 --- a/src/wireframes/model/serializer.spec.ts +++ b/src/wireframes/model/serializer.spec.ts @@ -6,12 +6,11 @@ */ import { Vec2 } from '@app/core/utils'; -import { Diagram, DiagramItem, DiagramItemSet, EditorState, RendererService, Serializer } from '@app/wireframes/model'; +import { Diagram, DiagramItem, DiagramItemSet, EditorState, PluginRegistry, Registration, Serializer } from '@app/wireframes/model'; import { Checkbox } from '@app/wireframes/shapes/neutral/checkbox'; -import { AbstractControl } from './../shapes/utils/abstract-control'; describe('Serializer', () => { - const checkbox = new AbstractControl(new Checkbox()); + const checkbox = new Registration(new Checkbox()); const groupId = 'group-1'; const oldShape1 = DiagramItem.createShape(checkbox.createDefaultShape()).transformWith(t => t.moveTo(new Vec2(100, 20))).rename('Named'); @@ -19,7 +18,7 @@ describe('Serializer', () => { const brokenShape = DiagramItem.createShape({ renderer: null! }); beforeEach(() => { - RendererService.addRenderer(checkbox); + PluginRegistry.addPlugin(checkbox.plugin); }); it('should serialize and deserialize set', () => { diff --git a/src/wireframes/model/serializer.ts b/src/wireframes/model/serializer.ts index 08a1be87..8552cf7c 100644 --- a/src/wireframes/model/serializer.ts +++ b/src/wireframes/model/serializer.ts @@ -10,7 +10,7 @@ import { Diagram } from './diagram'; import { DiagramItem } from './diagram-item'; import { DiagramItemSet } from './diagram-item-set'; import { EditorState } from './editor-state'; -import { RendererService } from './renderer.service'; +import { PluginRegistry } from './registry'; import { Transform } from './transform'; type IdMap = { [id: string]: string }; @@ -147,7 +147,7 @@ function readDiagramItem(source: object, type?: any) { const raw: any = readObject(source, DIAGRAM_ITEM_SERIALIZERS); if ((raw.type || type) === 'Shape') { - const defaults = RendererService.get(raw.renderer!)?.createDefaultShape(); + const defaults = PluginRegistry.get(raw.renderer!)?.createDefaultShape(); if (!defaults) { return null; diff --git a/src/wireframes/model/snap-manager.ts b/src/wireframes/model/snap-manager.ts index 444e7417..a60ed823 100644 --- a/src/wireframes/model/snap-manager.ts +++ b/src/wireframes/model/snap-manager.ts @@ -19,6 +19,8 @@ const RESIZE_MINIMUM = 1; export type SnapMode = 'None' | 'Grid' | 'Shapes'; export type SnapSide = 'Left' | 'Right' | 'Top' | 'Bottom'; +const EMPTY_SET = new Set(); + export type SnapResult = { snapX?: SnapLine; snapY?: SnapLine; @@ -97,7 +99,7 @@ export class SnapManager { } } - public snapResizing(transform: Transform, delta: Vec2, snapMode: SnapMode, xMode = 1, yMode = 1, ignoreList: Record = {}): SnapResult { + public snapResizing(transform: Transform, delta: Vec2, snapMode: SnapMode, xMode = 1, yMode = 1, ignoreList?: Set): SnapResult { const result: SnapResult = { delta }; let dw = delta.x; @@ -200,7 +202,7 @@ export class SnapManager { return result; } - public snapMoving(transform: Transform, delta: Vec2, snapMode: SnapMode, ignoreList: Record = {}): SnapResult { + public snapMoving(transform: Transform, delta: Vec2, snapMode: SnapMode, ignoreList?: Set): SnapResult { const result: SnapResult = { delta }; const aabb = transform.aabb; @@ -307,7 +309,7 @@ export class SnapManager { return result; } - public getSnapLines(ignoreList: Record) { + public getSnapLines(ignoreList?: Set) { if (this.xLines && this.yLines && this.grid) { return { xLines: this.xLines, yLines: this.yLines, grid: this.grid }; } @@ -324,8 +326,10 @@ export class SnapManager { return { xLines, yLines, grid: grid }; } + const ignore = ignoreList || EMPTY_SET; + // Compute the bounding boxes once. - const bounds = Array.from(currentDiagram.items.values).filter(x => !ignoreList[x.id]).map(x => x.bounds(currentDiagram).aabb); + const bounds = Array.from(currentDiagram.items.values).filter(x => !ignore.has(x.id)).map(x => x.bounds(currentDiagram).aabb); const grid = this.grid = computeGrid(bounds); @@ -388,7 +392,7 @@ export class SnapManager { return { xLines, yLines, grid }; } - public getDebugLines(ignoreList: Record = {}) { + public getDebugLines(ignoreList?: Set) { const { xLines, yLines } = this.getSnapLines(ignoreList); if (this.grid) { diff --git a/src/wireframes/model/transform.ts b/src/wireframes/model/transform.ts index a7ca9ea5..abc77b3f 100644 --- a/src/wireframes/model/transform.ts +++ b/src/wireframes/model/transform.ts @@ -8,7 +8,7 @@ /* eslint-disable no-multi-assign */ import { Rect2, Rotation, Vec2 } from '@app/core/utils'; -import { Constraint } from './../interface'; +import { Constraint } from '@app/wireframes/interface'; const EPSILON = 0.1; diff --git a/src/wireframes/renderer/Editor.tsx b/src/wireframes/renderer/Editor.tsx index 0428f277..ff0a8067 100644 --- a/src/wireframes/renderer/Editor.tsx +++ b/src/wireframes/renderer/Editor.tsx @@ -7,20 +7,19 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import * as svg from '@svgdotjs/svg.js'; import * as React from 'react'; -import { Color, Subscription, SVGHelper, Vec2, ViewBox } from '@app/core'; +import { Color, Subscription, Vec2, ViewBox } from '@app/core'; +import { Engine, EngineLayer, EngineRect } from '@app/wireframes/engine'; +import { SvgCanvasView } from '@app/wireframes/engine/svg/canvas/SvgCanvas'; import { Diagram, DiagramItem, DiagramItemSet, Transform } from '@app/wireframes/model'; import { useOverlayContext } from './../contexts/OverlayContext'; -import { CanvasView } from './CanvasView'; +import { ItemsLayer } from './ItemsLayer'; import { NavigateAdorner } from './NavigateAdorner'; import { QuickbarAdorner } from './QuickbarAdorner'; -import { RenderLayer } from './RenderLayer'; import { SelectionAdorner } from './SelectionAdorner'; import { TextAdorner } from './TextAdorner'; import { TransformAdorner } from './TransformAdorner'; import { InteractionOverlays } from './interaction-overlays'; -import { InteractionService } from './interaction-service'; import { PreviewEvent } from './preview'; import './Editor.scss'; @@ -78,66 +77,54 @@ export const Editor = React.memo((props: EditorProps) => { viewSize, } = props; - const adornerSelectLayer = React.useRef(); - const adornerTransformLayer = React.useRef(); - const backgroundLayer = React.useRef(); + const [engine, setEngine] = React.useState(); + const adornerSelectLayer = React.useRef(); + const adornerTransformLayer = React.useRef(); const overlayContext = useOverlayContext(); - const overlayLayer = React.useRef(); - const renderMainLayer = React.useRef(); - const renderMasterLayer = React.useRef(); - const [interactionMasterService, setInteractionMasterService] = React.useState(); - const [interactionMainService, setInteractionMainService] = React.useState(); + const overlayLayer = React.useRef(); + const renderMainLayer = React.useRef(); + const renderMasterLayer = React.useRef(); + const [backgroundRect, setBackgroundRect] = React.useState(); // Use a stream of preview updates to bypass react for performance reasons. const renderPreview = React.useRef(new Subscription()); - - const doInit = React.useCallback((doc: svg.Svg) => { + + const doInit = React.useCallback((engine: Engine) => { // Might be called multiple times in dev mode! - if (renderMainLayer.current) { + if (renderMainLayer.current || !engine) { return; } // Create these layers in the correct order. - backgroundLayer.current = doc.rect().id('background').stroke('#efefef').fill(color.toString()); - renderMasterLayer.current = doc.group().id('masterLayer'); - renderMainLayer.current = doc.group().id('parentLayer'); - adornerSelectLayer.current = doc.group().id('selectLayer'); - adornerTransformLayer.current = doc.group().id('transformLayer'); - overlayLayer.current = doc.group().id('overlaysLayer'); - - setInteractionMainService(new InteractionService([ - adornerSelectLayer.current, - adornerTransformLayer.current], - renderMainLayer.current, doc)); - - setInteractionMasterService(new InteractionService([ - adornerSelectLayer.current, - adornerTransformLayer.current], - renderMasterLayer.current, doc)); + const background = engine.layer('background').rect(); + background.disable(); + background.fill(color.toString()); + background.strokeWidth(1); + background.strokeColor('#efefef'); + setBackgroundRect(background); + + renderMasterLayer.current = engine.layer('masterLayer'); + renderMainLayer.current = engine.layer('parentLayer'); + overlayLayer.current = engine.layer('overlaysLayer'); + adornerSelectLayer.current = engine.layer('selectLayer'); + adornerTransformLayer.current = engine.layer('transformLayer'); + + engine.setClickLayer(renderMainLayer.current); if (isDefaultView) { overlayContext.overlayManager = new InteractionOverlays(overlayLayer.current); } + + setEngine(engine); }, []); React.useEffect(() => { - if (!interactionMainService) { - return; - } - - const w = viewSize.x; - const h = viewSize.y; - - SVGHelper.setSize(adornerSelectLayer.current!, w, h); - SVGHelper.setSize(adornerTransformLayer.current!, w, h); - SVGHelper.setSize(backgroundLayer.current!, w, h); - SVGHelper.setSize(renderMasterLayer.current!, w, h); - SVGHelper.setSize(renderMainLayer.current!, w, h); - }, [viewSize, interactionMainService]); + backgroundRect?.plot({ x: 0, y: 0, w: viewSize.x, h: viewSize.y }); + }, [backgroundRect, viewSize]); React.useEffect(() => { - backgroundLayer.current?.fill(color.toString()); - }, [color]); + backgroundRect?.fill(color.toString()); + }, [backgroundRect, color]); React.useEffect(() => { overlayContext.snapManager.prepare(diagram, viewSize); @@ -147,20 +134,29 @@ export const Editor = React.memo((props: EditorProps) => { (overlayContext.overlayManager as any)['setZoom']?.(viewBox.zoom); }, [diagram, overlayContext.overlayManager, viewBox.zoom]); + const showAdorners = React.useCallback(() => { + adornerSelectLayer.current?.show(); + adornerTransformLayer.current?.show(); + }, []); + + const hideAdorners = React.useCallback(() => { + adornerSelectLayer.current?.hide(); + adornerTransformLayer.current?.hide(); + }, []); + return (
overlayContext.element = element}> - + - {interactionMainService && diagram && ( + {engine && diagram && ( <> - - { {onTransformItems && { {onSelectItems && { {onChangeItemsAppearance && } @@ -209,17 +207,12 @@ export const Editor = React.memo((props: EditorProps) => { previewStream={renderPreview.current} selectedDiagram={diagram} selectionSet={selectionSet} - viewSize={viewSize} - zoom={viewBox.zoom} + viewBox={viewBox} /> } {onNavigate && - - } - - {onNavigate && interactionMasterService && - + } )} diff --git a/src/wireframes/renderer/RenderLayer.tsx b/src/wireframes/renderer/ItemsLayer.tsx similarity index 65% rename from src/wireframes/renderer/RenderLayer.tsx rename to src/wireframes/renderer/ItemsLayer.tsx index 511d7ae3..8d4dc724 100644 --- a/src/wireframes/renderer/RenderLayer.tsx +++ b/src/wireframes/renderer/ItemsLayer.tsx @@ -5,22 +5,18 @@ * Copyright (c) Sebastian Stehle. All rights reserved. */ -import * as svg from '@svgdotjs/svg.js'; import * as React from 'react'; -import { Color, ImmutableList, Subscription } from '@app/core'; -import { Diagram, DiagramItem, RendererService } from '@app/wireframes/model'; +import { ImmutableList, Subscription } from '@app/core'; +import { EngineItem, EngineLayer } from '@app/wireframes/engine'; +import { Diagram, DiagramItem, PluginRegistry } from '@app/wireframes/model'; import { PreviewEvent } from './preview'; -import { ShapeRef } from './shape-ref'; - -export interface RenderLayerProps { - // The background color. - background?: Color; +export interface ItemsLayerProps { // The selected diagram. diagram?: Diagram; // The container to render on. - diagramLayer: svg.Container; + diagramLayer: EngineLayer; // The preview subscription. preview?: Subscription; @@ -29,18 +25,16 @@ export interface RenderLayerProps { onRender?: () => void; } -const showDebugOutlines = false; - -export const RenderLayer = React.memo((props: RenderLayerProps) => { +export const ItemsLayer = React.memo((props: ItemsLayerProps) => { const { diagram, diagramLayer, - preview, onRender, + preview, } = props; const shapesRendered = React.useRef(onRender); - const shapeRefsById = React.useRef<{ [id: string]: ShapeRef }>({}); + const shapeRefsById = React.useRef>({}); const itemIds = diagram?.rootIds; const items = diagram?.items; @@ -96,18 +90,17 @@ export const RenderLayer = React.memo((props: RenderLayerProps) => { // Create missing shapes. for (const shape of allShapes) { if (!references[shape.id]) { - const rendererInstance = RendererService.get(shape.renderer); + const plugin = PluginRegistry.get(shape.renderer)?.plugin; - if (!rendererInstance) { + if (!plugin) { throw new Error(`Cannot find renderer for ${shape.renderer}.`); } - references[shape.id] = new ShapeRef(diagramLayer, rendererInstance, showDebugOutlines); + references[shape.id] = new ItemWithPreview(diagramLayer.item(plugin)); } } let hasIdChanged = false; - for (let i = 0; i < allShapes.length; i++) { if (!references[allShapes[i].id].checkIndex(i)) { hasIdChanged = true; @@ -118,12 +111,12 @@ export const RenderLayer = React.memo((props: RenderLayerProps) => { // If the index of at least once shape has changed we have to remove them all to render them in the correct order. if (hasIdChanged) { for (const ref of Object.values(references)) { - ref.remove(); + ref.detach(); } } for (const shape of allShapes) { - references[shape.id].render(shape); + references[shape.id].plot(shape); } if (shapesRendered.current) { @@ -135,11 +128,11 @@ export const RenderLayer = React.memo((props: RenderLayerProps) => { return preview?.subscribe(event => { if (event.type === 'Update') { for (const item of Object.values(event.items)) { - shapeRefsById.current[item.id]?.setPreview(item); + shapeRefsById.current[item.id]?.preview(item); } } else { for (const reference of Object.values(shapeRefsById.current)) { - reference.setPreview(null); + reference.preview(null); } } }); @@ -147,3 +140,44 @@ export const RenderLayer = React.memo((props: RenderLayerProps) => { return null; }); + +class ItemWithPreview { + private shapePreview: DiagramItem | null = null; + private shapeStatic: DiagramItem | null = null; + private currentIndex = -1; + + constructor( + private readonly engineItem: EngineItem, + ) { + } + + public plot(shape: DiagramItem) { + this.shapeStatic = shape; + this.shapePreview = null; + this.render(); + } + + public preview(shape: DiagramItem | null) { + this.shapePreview = shape; + this.render(); + } + + public checkIndex(index: number) { + const result = this.currentIndex >= 0 && this.currentIndex !== index; + + this.currentIndex = index; + return result; + } + + public detach() { + this.engineItem.detach(); + } + + public remove() { + this.engineItem.remove(); + } + + private render() { + this.engineItem.plot(this.shapePreview || this.shapeStatic); + } +} diff --git a/src/wireframes/renderer/NavigateAdorner.tsx b/src/wireframes/renderer/NavigateAdorner.tsx index b1dd8afa..136e7497 100644 --- a/src/wireframes/renderer/NavigateAdorner.tsx +++ b/src/wireframes/renderer/NavigateAdorner.tsx @@ -6,27 +6,27 @@ */ import * as React from 'react'; +import { Engine, HitEvent, Listener } from '@app/wireframes/engine'; import { DiagramItem } from '@app/wireframes/model'; -import { InteractionHandler, InteractionService, SvgEvent } from './interaction-service'; export interface NavigateAdornerProps { // The interaction service. - interactionService: InteractionService; + engine: Engine; // A function that is invoked when the user clicked a link. onNavigate: (item: DiagramItem, link: string) => void; } -export class NavigateAdorner extends React.PureComponent implements InteractionHandler { +export class NavigateAdorner extends React.PureComponent implements Listener { public componentDidMount() { - this.props.interactionService.addHandler(this); + this.props.engine.subscribe(this); } public componentWillUnmount() { - this.props.interactionService.removeHandler(this); + this.props.engine.unsubscribe(this); } - public onClick(event: SvgEvent, next: (event: SvgEvent) => void) { + public onClick(event: HitEvent, next: (event: HitEvent) => void) { const target = getShapeWithLink(event); if (target) { @@ -38,7 +38,7 @@ export class NavigateAdorner extends React.PureComponent i return false; } - public onMouseMove(event: SvgEvent, next: (event: SvgEvent) => void) { + public onMouseMove(event: HitEvent, next: (event: HitEvent) => void) { if (getShapeWithLink(event)) { document.body.style.cursor = 'pointer'; } else { @@ -53,11 +53,11 @@ export class NavigateAdorner extends React.PureComponent i } } -function getShapeWithLink(event: SvgEvent) { - const link = event.shape?.link; +function getShapeWithLink(event: HitEvent) { + const link = event.item?.link; if (link) { - return { shape: event.shape, link }; + return { shape: event.item, link }; } else { return null; } diff --git a/src/wireframes/renderer/QuickbarAdorner.tsx b/src/wireframes/renderer/QuickbarAdorner.tsx index 3d700b50..77f1e0b6 100644 --- a/src/wireframes/renderer/QuickbarAdorner.tsx +++ b/src/wireframes/renderer/QuickbarAdorner.tsx @@ -6,18 +6,15 @@ */ import * as React from 'react'; -import { Rect2, sizeInPx, Subscription, Vec2 } from '@app/core'; +import { Rect2, sizeInPx, Subscription, ViewBox } from '@app/core'; import { ActionButton, useAlignment } from '@app/wireframes/components/actions'; import { Diagram, DiagramItemSet } from '@app/wireframes/model'; import { PreviewEvent } from './preview'; import './QuickbarAdorner.scss'; export interface QuickbarAdornerProps { - // The current zoom value. - zoom: number; - - // The size of the view. - viewSize: Vec2; + // The viewbox. + viewBox: ViewBox; // The selected diagram. selectedDiagram: Diagram; @@ -34,7 +31,7 @@ export const QuickbarAdorner = (props: QuickbarAdornerProps) => { previewStream, selectedDiagram, selectionSet, - zoom, + viewBox, } = props; const forAlignment = useAlignment(); @@ -75,8 +72,8 @@ export const QuickbarAdorner = (props: QuickbarAdornerProps) => { return null; } - const x = sizeInPx(Math.round(zoom * Math.max(0, selectionRect.x))); - const y = sizeInPx(Math.round(zoom * selectionRect.y - 100)); + const x = sizeInPx(-viewBox.minX + Math.round(viewBox.zoom * Math.max(0, selectionRect.x))); + const y = sizeInPx(-viewBox.minY + Math.round(viewBox.zoom * selectionRect.y - 100)); return (
diff --git a/src/wireframes/renderer/SelectionAdorner.tsx b/src/wireframes/renderer/SelectionAdorner.tsx index 396bb2da..3536a7b5 100644 --- a/src/wireframes/renderer/SelectionAdorner.tsx +++ b/src/wireframes/renderer/SelectionAdorner.tsx @@ -5,22 +5,25 @@ * Copyright (c) Sebastian Stehle. All rights reserved. */ -import * as svg from '@svgdotjs/svg.js'; import * as React from 'react'; -import { isMiddleMouse, isModKey, Rect2, Subscription, SVGHelper, Vec2 } from '@app/core'; +import { isMiddleMouse, isModKey, Rect2, Subscription, Vec2 } from '@app/core'; +import { Engine, EngineLayer, EngineRect, HitEvent, Listener } from '@app/wireframes/engine'; import { calculateSelection, Diagram, DiagramItem, DiagramItemSet } from '@app/wireframes/model'; -import { InteractionHandler, InteractionService, SvgEvent } from './interaction-service'; import { PreviewEvent } from './preview'; const SELECTION_STROKE_COLOR = '#080'; const SELECTION_STROKE_LOCK_COLOR = '#f00'; +const SELECTOR_STROKE_COLOR = '#0a0'; export interface SelectionAdornerProps { // The current zoom value. zoom: number; - // The adorner scope. - adorners: svg.Container; + // The target layer + layer: EngineLayer; + + // The current engine + engine: Engine; // The selected diagram. selectedDiagram: Diagram; @@ -28,9 +31,6 @@ export interface SelectionAdornerProps { // The selected items. selectionSet: DiagramItemSet; - // The interaction service. - interactionService: InteractionService; - // The preview subscription. previewStream: Subscription; @@ -38,13 +38,13 @@ export interface SelectionAdornerProps { onSelectItems: (diagram: Diagram, itemIds: ReadonlyArray) => any; } -export class SelectionAdorner extends React.Component implements InteractionHandler { - private selectionMarkers: svg.Rect[] = []; - private selectionShape!: svg.Rect; +export class SelectionAdorner extends React.Component implements Listener { + private selectionMarkers: EngineRect[] = []; + private selectionShape!: EngineRect; private dragStart: Vec2 | null = null; public componentDidMount() { - this.props.interactionService.addHandler(this); + this.props.engine.subscribe(this); // Use a stream of preview updates to bypass react for performance reasons. this.props.previewStream.subscribe(event => { @@ -55,16 +55,15 @@ export class SelectionAdorner extends React.Component imp } }); - this.selectionShape = - this.props.adorners.rect(1, 1) - .stroke({ color: '#0a0', width: 1 }) - .scale(1, 1) - .fill('#00aa0044') - .hide(); + this.selectionShape = this.props.layer.rect(); + this.selectionShape.fill('#00aa0044'); + this.selectionShape.strokeWidth(1); + this.selectionShape.strokeColor(SELECTOR_STROKE_COLOR); + this.selectionShape.hide(); } public componentWillUnmount() { - this.props.interactionService.removeHandler(this); + this.props.engine.unsubscribe(this); this.selectionMarkers = []; } @@ -73,7 +72,7 @@ export class SelectionAdorner extends React.Component imp this.markItems(); } - public onMouseDown(event: SvgEvent) { + public onMouseDown(event: HitEvent) { // The middle mouse button is needed for pan and zoom. if (isMiddleMouse(event.event)) { return; @@ -85,12 +84,12 @@ export class SelectionAdorner extends React.Component imp this.props.onSelectItems(this.props.selectedDiagram, selection); } - if (!event.element) { + if (!event.object) { this.dragStart = event.position; } } - public onMouseDrag(event: SvgEvent, next: (event: SvgEvent) => void) { + public onMouseDrag(event: HitEvent, next: (event: HitEvent) => void) { if (!this.dragStart) { next(event); return; @@ -100,11 +99,11 @@ export class SelectionAdorner extends React.Component imp this.transformShape(this.selectionShape, new Vec2(rect.x, rect.y), new Vec2(rect.w, rect.h), 0); - // Use the inverted zoom level as stroke width to have a constant stroke style. - this.selectionShape.stroke({ width: 1 / this.props.zoom }); + // Use the inverted zoom level as stroke width to have a constant stroke width. + this.selectionShape.strokeWidth(1 / this.props.zoom); } - public onMouseUp(event: SvgEvent, next: (event: SvgEvent) => void) { + public onMouseUp(event: HitEvent, next: (event: HitEvent) => void) { if (!this.dragStart) { next(event); return; @@ -148,15 +147,15 @@ export class SelectionAdorner extends React.Component imp return calculateSelection(selectedItems, diagram, false); } - private selectSingle(event: SvgEvent, diagram: Diagram): ReadonlyArray { + private selectSingle(event: HitEvent, diagram: Diagram): ReadonlyArray { const isMod = isModKey(event.event); if (isMod) { event.event.preventDefault(); } - if (event.shape) { - return calculateSelection([event.shape], diagram, true, isMod); + if (event.item) { + return calculateSelection([event.item], diagram, true, isMod); } else { return []; } @@ -171,7 +170,7 @@ export class SelectionAdorner extends React.Component imp // Add more markers if we do not have enough. while (this.selectionMarkers.length < selection.length) { - const marker = this.props.adorners.rect(1, 1).fill('none'); + const marker = this.props.layer.rect(); this.selectionMarkers.push(marker); } @@ -188,7 +187,9 @@ export class SelectionAdorner extends React.Component imp SELECTION_STROKE_COLOR; // Use the inverted zoom level as stroke width to have a constant stroke style. - marker.stroke({ color, width: strokeWidth }); + marker.strokeWidth(strokeWidth); + marker.strokeColor(color); + marker.fill('none'); const actualItem = item; const actualBounds = actualItem.bounds(this.props.selectedDiagram); @@ -198,15 +199,14 @@ export class SelectionAdorner extends React.Component imp }); } - protected transformShape(shape: svg.Rect, position: Vec2, size: Vec2, offset: number, rotation = 0) { - // We have to disable the adjustment mode to turn off the rounding. - SVGHelper.transformBy(shape, { + protected transformShape(shape: EngineRect, position: Vec2, size: Vec2, offset: number, rotation = 0) { + shape.plot({ x: position.x - 0.5 * offset, y: position.y - 0.5 * offset, w: size.x + offset, h: size.y + offset, rotation, - }, false); + }); if (size.x > 2 && size.y > 2) { shape.show(); diff --git a/src/wireframes/renderer/TextAdorner.tsx b/src/wireframes/renderer/TextAdorner.tsx index c8d60ef4..43f1def5 100644 --- a/src/wireframes/renderer/TextAdorner.tsx +++ b/src/wireframes/renderer/TextAdorner.tsx @@ -7,9 +7,9 @@ import * as React from 'react'; import { Keys, sizeInPx } from '@app/core'; +import { Engine, HitEvent, Listener } from '@app/wireframes/engine'; import { DefaultAppearance } from '@app/wireframes/interface'; import { Diagram, DiagramItem, DiagramItemSet } from '@app/wireframes/model'; -import { InteractionHandler, InteractionService, SvgEvent } from './interaction-service'; import './TextAdorner.scss'; const MIN_WIDTH = 150; @@ -25,26 +25,32 @@ export interface TextAdornerProps { // The selected items. selectionSet: DiagramItemSet; - // The interaction service. - interactionService: InteractionService; + // The engine. + engine: Engine; + + // A helper function to show adorners. + showAdorners: () => void; + + // A helper function to hideAdorners adorners. + hideAdorners: () => void; // A function to change the appearance of a visual. onChangeItemsAppearance: (diagram: Diagram, visuals: DiagramItem[], key: string, val: any) => any; } -export class TextAdorner extends React.PureComponent implements InteractionHandler { +export class TextAdorner extends React.PureComponent implements Listener { private readonly style = { display: 'none ' }; private selectedShape: DiagramItem | null = null; private textareaElement: HTMLTextAreaElement = null!; public componentDidMount() { - this.props.interactionService.addHandler(this); + this.props.engine.subscribe(this); window.addEventListener('mousedown', this.handleMouseDown); } public componentWillUnmount() { - this.props.interactionService.removeHandler(this); + this.props.engine.unsubscribe(this); window.removeEventListener('mousedown', this.handleMouseDown); } @@ -61,15 +67,15 @@ export class TextAdorner extends React.PureComponent implement } }; - public onDoubleClick(event: SvgEvent) { - if (event.shape && !event.shape.isLocked && this.textareaElement) { - if (event.shape.textDisabled) { + public onDoubleClick(event: HitEvent) { + if (event.item && !event.item.isLocked && this.textareaElement) { + if (event.item.textDisabled) { return; } const zoom = this.props.zoom; - const transform = event.shape.transform; + const transform = event.item.transform; const x = sizeInPx(zoom * (transform.position.x - 0.5 * transform.size.x) - 2); const y = sizeInPx(zoom * (transform.position.y - 0.5 * transform.size.y) - 2); @@ -77,7 +83,7 @@ export class TextAdorner extends React.PureComponent implement const w = sizeInPx(zoom * (Math.max(transform.size.x, MIN_WIDTH)) + 4); const h = sizeInPx(zoom * (Math.max(transform.size.y, MIN_HEIGHT)) + 4); - this.textareaElement.value = event.shape.text; + this.textareaElement.value = event.item.text; this.textareaElement.style.top = y; this.textareaElement.style.left = x; this.textareaElement.style.width = w; @@ -87,9 +93,8 @@ export class TextAdorner extends React.PureComponent implement this.textareaElement.style.position = 'absolute'; this.textareaElement.focus(); - this.props.interactionService.hideAdorners(); - - this.selectedShape = event.shape; + this.props.hideAdorners(); + this.selectedShape = event.item; } } @@ -137,16 +142,16 @@ export class TextAdorner extends React.PureComponent implement this.textareaElement.style.width = '0'; this.textareaElement.style.display = 'none'; - this.props.interactionService.showAdorners(); + this.props.showAdorners(); } public render() { return (