diff --git a/src/wireframes/engine/canvas.tsx b/src/wireframes/engine/canvas.tsx index 6583ecd..5793833 100644 --- a/src/wireframes/engine/canvas.tsx +++ b/src/wireframes/engine/canvas.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { ViewBox } from '@app/core'; import { Engine } from './interface'; -export interface CanvasProps { +export interface CanvasProps { // The optional viewbox. viewBox?: ViewBox; @@ -20,5 +20,5 @@ export interface CanvasProps { style?: React.CSSProperties; // The callback when the canvas has been initialized. - onInit: (engine: Engine) => any; + onInit: (engine: T) => any; } \ No newline at end of file diff --git a/src/wireframes/engine/elements.stories.tsx b/src/wireframes/engine/elements.stories.tsx index 0b8eb6c..568482d 100644 --- a/src/wireframes/engine/elements.stories.tsx +++ b/src/wireframes/engine/elements.stories.tsx @@ -56,29 +56,6 @@ const CompareView = (props: Omit) => { ); }; -const CompareCanvasViews = (props: Omit) => { - return ( - - - - - - } /> - - - - - - - } /> - - - - ); -}; - const HitTestForCanvas = (props: { canvasView: React.ComponentType }) => { const [engine, setEngine] = React.useState(); const [relativeX, setRelativeX] = React.useState(0); @@ -168,29 +145,6 @@ export const Hits = () => { ); }; -export const Pan = () => { - return ( - { - const layer = engine.layer('layer1'); - - const rect = layer.rect(); - rect.fill('blue'); - rect.strokeColor('red'); - rect.strokeWidth(2); - rect.plot({ x: 200, y: 250, w: 200, h: 100 }); - - const text = layer.text(); - text.color('white'); - text.fill('black'); - text.fontFamily('Arial'); - text.fontSize(16); - text.text('Hello Engine'); - text.plot({ x: 50, y: 100, w: 200, h: 60, padding: 5 }); - }} /> - ); -}; - export const Rect = () => { return ( any; -} - -const PixiCanvasViewComponent = (props: PixiCanvasProps & SizeMeProps) => { +const PixiCanvasViewComponent = (props: CanvasProps & SizeMeProps) => { const { className, onInit, diff --git a/src/wireframes/engine/pixi/item.ts b/src/wireframes/engine/pixi/item.ts index 2caf987..8f7d378 100644 --- a/src/wireframes/engine/pixi/item.ts +++ b/src/wireframes/engine/pixi/item.ts @@ -1,3 +1,4 @@ + /* * mydraft.cc * @@ -18,6 +19,7 @@ export class PixiItem extends PixiObject implements EngineItem { private readonly container: Container; private readonly selector: Graphics; private currentShape: DiagramItem | null = null; + private currentRect: Rect2 | null = null; private isRendered = false; protected get root() { @@ -66,18 +68,25 @@ export class PixiItem extends PixiObject implements EngineItem { } } - private renderCore(item: DiagramItem) { - const localRect = new Rect2(0, 0, item.transform.size.x, item.transform.size.y); + private renderCore(shape: DiagramItem) { + const localRect = new Rect2(0, 0, shape.transform.size.x, shape.transform.size.y); + + if (this.currentRect && + this.currentRect.equals(localRect) && + this.currentShape?.appearance === shape.appearance) { + this.arrangeContainer(shape); + return; + } const previousContainer = this.renderer.getContainer(); try { this.renderer.setContainer(this.container, 1); - - this.plugin.render({ renderer2: this.renderer, rect: localRect, shape: item }); + this.plugin.render({ renderer2: this.renderer, rect: localRect, shape: shape }); this.arrangeSelector(localRect); - this.arrangeContainer(item); + this.arrangeContainer(shape); } finally { + this.currentRect = localRect; this.renderer.cleanupAll(); this.renderer.setContainer(previousContainer); this.isRendered = true; diff --git a/src/wireframes/engine/pixi/renderer.ts b/src/wireframes/engine/pixi/renderer.ts index 1c0ac3c..818e5be 100644 --- a/src/wireframes/engine/pixi/renderer.ts +++ b/src/wireframes/engine/pixi/renderer.ts @@ -10,7 +10,7 @@ import { marked } from 'marked'; import { Assets, Container, ContainerChild, Graphics, GraphicsPath, HTMLText, Sprite, StrokeStyle, TextStyle, Texture, ViewContainer } from 'pixi.js'; import { Rect2, TextMeasurer, Types } from '@app/core/utils'; -import { RendererColor, RendererOpacity, RendererText, RendererWidth, Shape, ShapeProperties, ShapePropertiesFunc, ShapeRenderer, ShapeRendererFunc, TextConfig } from '@app/wireframes/interface'; +import { RendererColor, RendererOpacity, RendererText, RendererWidth, Shape, ShapeProperties, ShapePropertiesFunc, ShapeRenderer, ShapeRendererFunc, TextConfig, TextDecoration } from '@app/wireframes/interface'; import { getBackgroundColor, getFontFamily, getFontSize, getForegroundColor, getOpacity, getStrokeColor, getStrokeWidth, getText, getTextAlignment } from './../shared'; import { PixiHelper } from './utils'; @@ -22,7 +22,7 @@ type Properties = { radius: number; raster: { source: string; keepRatio?: boolean } | null; stroke: StrokeStyle; - textContent: { text?: string; markdown?: boolean }; + textContent: { text?: string; markdown?: boolean; underline?: boolean }; textStyle: Partial; textMode?: 'Single' | 'Multi'; }; @@ -157,7 +157,7 @@ const FACTORY_TEXT = (_: Properties, existing?: HTMLText) => { const mask = new Graphics(); existing.mask = mask; - existing.resolution = 4; + existing.resolution = 2; existing.addChild(mask); } @@ -189,52 +189,64 @@ export class PixiRenderer implements ShapeRenderer { return bounds; } - public rectangle(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const w = getStrokeWidth(strokeWidth); - - return this.new(FACTORY_RECTANGLE, IS_GRAPHICS, { bounds, radius, stroke: { width: w } }, properties); + public rectangle(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { + return this.new(IS_GRAPHICS, FACTORY_RECTANGLE, { + bounds, + radius, + stroke: { width: getStrokeWidth(strokeWidth) }, + }, properties); } - public ellipse(strokeWidth: RendererWidth, bounds: Rect2, properties?: ShapePropertiesFunc) { - const w = getStrokeWidth(strokeWidth); - - return this.new(FACTORY_ELLIPSE, IS_GRAPHICS, { bounds, stroke: { width: w } }, properties); + public ellipse(strokeWidth: RendererWidth, bounds: Rect2, properties?: ShapePropertiesFunc) { + return this.new(IS_GRAPHICS, FACTORY_ELLIPSE, { + bounds, + stroke: { width: getStrokeWidth(strokeWidth) }, + }, properties); } - public roundedRectangleLeft(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const w = getStrokeWidth(strokeWidth); - - return this.new(FACTORY_ROUNDED_RECTANGLE_LEFT, IS_GRAPHICS, { bounds, radius, stroke: { width: w } }, properties); + public roundedRectangleLeft(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { + return this.new(IS_GRAPHICS, FACTORY_ROUNDED_RECTANGLE_LEFT, { + bounds, + radius, + stroke: { width: getStrokeWidth(strokeWidth) }, + }, properties); } public roundedRectangleRight(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const w = getStrokeWidth(strokeWidth); - - return this.new(FACTORY_ROUNDED_RECTANGLE_RIGHT, IS_GRAPHICS, { bounds, radius, stroke: { width: w } }, properties); + return this.new(IS_GRAPHICS, FACTORY_ROUNDED_RECTANGLE_RIGHT, { + bounds, + radius, + stroke: { width: getStrokeWidth(strokeWidth) }, + }, properties); } public roundedRectangleTop(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const w = getStrokeWidth(strokeWidth); - - return this.new(FACTORY_ROUNDED_RECTANGLE_TOP, IS_GRAPHICS, { bounds, radius, stroke: { width: w } }, properties); + return this.new(IS_GRAPHICS, FACTORY_ROUNDED_RECTANGLE_TOP, { + bounds, + radius, + stroke: { width: getStrokeWidth(strokeWidth) }, + }, properties); } - public roundedRectangleBottom(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const w = getStrokeWidth(strokeWidth); - - return this.new(FACTORY_ROUNDED_RECTANGLE_BOTTOM, IS_GRAPHICS, { bounds, radius, stroke: { width: w } }, properties); + public roundedRectangleBottom(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { + return this.new(IS_GRAPHICS, FACTORY_ROUNDED_RECTANGLE_BOTTOM, { + bounds, + radius, + stroke: { width: getStrokeWidth(strokeWidth) }, + }, properties); } public path(strokeWidth: RendererWidth, path: string, properties?: ShapePropertiesFunc) { - const w = getStrokeWidth(strokeWidth); - - return this.new(FACTORY_PATH, IS_GRAPHICS, { path, stroke: { width: w } }, properties); + return this.new(IS_GRAPHICS, FACTORY_PATH, { + path, + stroke: { width: getStrokeWidth(strokeWidth) }, + }, properties); } public text(config: RendererText, bounds: Rect2, properties?: ShapePropertiesFunc, allowMarkdown?: boolean) { const fontSize = getFontSize(config); - return this.new(FACTORY_TEXT, IS_TEXT, { + return this.new(IS_TEXT, FACTORY_TEXT, { bounds, textStyle: { align: getTextAlignment(config) as any, @@ -250,7 +262,7 @@ export class PixiRenderer implements ShapeRenderer { public textMultiline(config: RendererText, bounds: Rect2, properties?: ShapePropertiesFunc, allowMarkdown?: boolean) { const fontSize = getFontSize(config); - return this.new(FACTORY_TEXT, IS_TEXT, { + return this.new(IS_TEXT, FACTORY_TEXT, { bounds, textStyle: { align: getTextAlignment(config) as any, @@ -265,11 +277,11 @@ export class PixiRenderer implements ShapeRenderer { } public raster(source: string, bounds: Rect2, preserveAspectRatio?: boolean, properties?: ShapePropertiesFunc) { - return this.new(FACTORY_SPRITE, IS_SPRITE, { bounds, raster: { source, keepRatio: preserveAspectRatio } }, properties); + return this.new(IS_SPRITE, FACTORY_SPRITE, { bounds, raster: { source, keepRatio: preserveAspectRatio } }, properties); } public group(items: ShapeRendererFunc, clip?: ShapeRendererFunc, properties?: ShapePropertiesFunc) { - return this.new(FACTORY_GROUP, IS_GROUP, {}, properties, group => { + return this.new( IS_GROUP, FACTORY_GROUP, {}, properties, group => { const prevClipping = this.clipping; const prevContainer = this.currentContainer; const prevIndex = this.currentIndex; @@ -295,7 +307,7 @@ export class PixiRenderer implements ShapeRenderer { }); } - private new(factory: Factory, test: Test, initialProperties: Partial, + private new(test: Test, factory: Factory, initialProperties: Partial, customProperties?: ShapePropertiesFunc, customInitializer?: (element: T) => void) { if (this.wasClipped) { @@ -312,7 +324,7 @@ export class PixiRenderer implements ShapeRenderer { path: null, radius: 0, raster: null, - stroke: { alignment: 1 }, + stroke: {}, textContent: {}, textStyle: {}, }; @@ -335,16 +347,6 @@ export class PixiRenderer implements ShapeRenderer { existing?.removeFromParent(); existing = undefined; } - - const oldProperties = (existing as any)?.['properties']; - - const invalid = !oldProperties || Types.equals(properties, oldProperties); - if (!invalid) { - if (!this.clipping) { - this.currentIndex++; - } - return; - } const element = factory(properties, existing as T); @@ -383,20 +385,20 @@ type Setters = { class PropertiesSetter implements ShapeProperties { private static readonly SETTERS: Setters = { - 'opacity': (value, element) => { + opacity: (value, element) => { element.alpha = value; }, - 'fill': (value, element) => { + fill: (value, element) => { if (Types.is(element, Graphics)) { element.fill(value as any); } }, - 'stroke': (value, element) => { + stroke: (value, element) => { if (Types.is(element, Graphics)) { element.stroke({ alignment: 1, ...value }); } }, - 'raster': (value, element) => { + raster: (value, element, p) => { if (Types.is(element, Sprite)) { (element as any)['source'] = value; if (!value) { @@ -427,11 +429,11 @@ class PropertiesSetter implements ShapeProperties { if (ratioImage > ratioElement) { w = size.width; h = size.width / ratioImage; - element.y = (size.height - h) * 0.5; + element.y = p.bounds.y + (size.height - h) * 0.5; } else { w = size.height * ratioImage; h = size.height; - element.x = (size.width - w) * 0.5; + element.x = p.bounds.x + (size.width - w) * 0.5; } element.width = w; @@ -442,51 +444,44 @@ class PropertiesSetter implements ShapeProperties { }); } }, - 'textContent': (value, element) => { + textContent: (value, element) => { if (Types.is(element, HTMLText)) { let textOrHtml = value.text; if (value.markdown && textOrHtml) { textOrHtml = marked.parseInline(textOrHtml) as string; } + if (value.underline && textOrHtml) { + textOrHtml = `${textOrHtml}`; + } + element.text = textOrHtml || ''; } }, - 'textStyle': (value, element, p) => { + textStyle: (value, element) => { if (Types.is(element, HTMLText)) { element.style = { padding: 10, ...value }; - - const { bounds } = p; - - const mask = element.mask as Graphics; - mask?.clear(); - mask?.rect(bounds.x, bounds.y, bounds.w - 0.25 * element.style.fontSize, bounds.h).fill(0xffffff); - - const size = element.getSize(); - const x = Math.max((bounds.w - size.width) * 0.5, 0.25 * element.style.fontSize); - const y = Math.max((bounds.h - size.height) * 0.5, 0); - element.position = { x, y }; } }, - 'textMode': (value, element, p) => { + textMode: (value, element, p) => { if (Types.is(element, HTMLText)) { const { bounds } = p; const mask = element.mask as Graphics; mask?.clear(); - mask?.rect(bounds.x, bounds.y, bounds.w, bounds.h).fill(0xffffff); + mask?.rect(0, 0, bounds.w, bounds.h).fill(0xffffff); const size = element.getSize(); - let x = 0; - let y = 0; + let x = bounds.x; + let y = bounds.y; if (value === 'Single') { - y = Math.max((bounds.h - size.height) * 0.5, 0); + y += Math.max((bounds.h - size.height) * 0.5, 0); } if (element.style.align === 'center') { - x = Math.max((bounds.w - size.width) * 0.5, 0); + x += Math.max((bounds.w - size.width) * 0.5, 0); } else if (element.style.align === 'right') { - x = bounds.w - size.width; + x += bounds.w - size.width; } element.position = { x, y }; @@ -500,33 +495,40 @@ class PropertiesSetter implements ShapeProperties { public static readonly INSTANCE = new PropertiesSetter(); public apply(element: ContainerChild) { + const oldProperties = (element as any)['properties'] || {} as any; + for (const [property, setter] of PropertiesSetter.SETTERS_ENTRIES) { const value = (this.properties as any)[property]; - setter(value as never, element, this.properties); + if (!Types.equals(value, oldProperties[property])) { + setter(value as never, element, this.properties); + } } + + (element as any)['properties'] = this.properties; } public setProperties(properties: Properties) { this.properties = properties; } - public setTextDecoration(): ShapeProperties { + public setTextDecoration(decoration: TextDecoration): ShapeProperties { + this.properties.textContent.underline = decoration === 'underline'; return this; } public setBackgroundColor(color: RendererColor | null | undefined): ShapeProperties { - this.properties.fill = getBackgroundColor(color)?.toString(); + this.properties.fill = PixiHelper.toColor(getBackgroundColor(color)); return this; } public setForegroundColor(color: RendererColor | null | undefined): ShapeProperties { - this.properties.textStyle.fill = getForegroundColor(color)?.toString()!; + this.properties.textStyle.fill = PixiHelper.toColor(getForegroundColor(color)); return this; } public setStrokeColor(color: RendererColor | null | undefined): ShapeProperties { - this.properties.stroke.color = getStrokeColor(color)?.toString(); + this.properties.stroke.color = PixiHelper.toColor(getStrokeColor(color)); return this; } diff --git a/src/wireframes/engine/pixi/utils.ts b/src/wireframes/engine/pixi/utils.ts index 2214024..bd54871 100644 --- a/src/wireframes/engine/pixi/utils.ts +++ b/src/wireframes/engine/pixi/utils.ts @@ -6,7 +6,7 @@ */ import { Container, Graphics } from 'pixi.js'; -import { Rect2 } from '@app/core'; +import { Color, Rect2, Types } from '@app/core'; import { PixiLayer } from './layer'; import { PixiObject } from './object'; @@ -94,4 +94,14 @@ export module PixiHelper { g.lineTo(l, t); g.closePath(); } + + export function toColor(value: string | number | Color | null | undefined): string { + if (Types.isString(value)) { + return value; + } else if (value) { + return Color.fromValue(value).toString(); + } else { + return 'black'; + } + } } \ No newline at end of file diff --git a/src/wireframes/engine/renderer.stories.tsx b/src/wireframes/engine/renderer.stories.tsx index a4d22aa..a3115e6 100644 --- a/src/wireframes/engine/renderer.stories.tsx +++ b/src/wireframes/engine/renderer.stories.tsx @@ -10,7 +10,8 @@ import { Card, Col, Row } from 'antd'; import * as React from 'react'; import { Color, Rect2, Rotation, Vec2 } from '@app/core'; import { RenderContext, ShapeRenderer } from '@app/wireframes/interface'; -import { DiagramItem, Transform } from '@app/wireframes/model'; +import { DefaultConstraintFactory, DiagramItem, Transform } from '@app/wireframes/model'; +import { Checkbox } from '../shapes/dependencies'; import { CanvasProps } from './canvas'; import { Engine } from './interface'; import { PixiCanvasView } from './pixi/canvas/PixiCanvas'; @@ -78,6 +79,38 @@ const EngineCanvas = (props: EngineProps & { canvasView: React.ComponentType }) => { + const [engine, setEngine] = React.useState(); + + React.useEffect(() => { + if (engine) { + const plugin = new Checkbox(); + + const item = engine.layer('default').item(plugin); + const size = plugin.defaultSize(); + const shape = + DiagramItem.createShape({ + renderer: plugin.identifier(), + transform: new Transform( + new Vec2(0.5 * size.x, 0.5 * size.y), + new Vec2(size.x, size.y), + Rotation.ZERO), + appearance: { ...plugin.defaultAppearance(), STATE: 'Checked' }, + configurables: [], + constraint: plugin?.constraint?.(DefaultConstraintFactory.INSTANCE), + }); + + item.plot(shape); + } + }, [engine]); + + return ( +
+ +
+ ); +}; + const CompareView = (props: EngineProps) => { return ( @@ -95,10 +128,33 @@ const CompareView = (props: EngineProps) => { ); }; +const CheckboxCompareView = () => { + return ( + + + + + + + + + + + + + ); +}; + export default { component: CompareView, } as Meta; +export const Complex = () => { + return ( + + ); +}; + export const Rect = () => { return ( { /> ); }; + export const RotatedRect = () => { return ( { ); }; +export const TextCenterOffset = () => { + return ( + + renderer.group(i => { + i.rectangle(1, 0, new Rect2(50, 50, 100, 60), p => p + .setStrokeColor('black'), + ); + i.text({ text: 'Hello Engine', alignment: 'center' }, new Rect2(50, 50, 100, 60), p => p + .setBackgroundColor('#aaa') + .setStrokeColor(color), + ); + }) + } + /> + ); +}; + export const TextUnderline = () => { return ( { i.rectangle(1, 0, new Rect2(0, 0, 100, 60), p => p .setStrokeColor('black'), ); - i.text({ text: 'y j g', alignment: 'center' }, new Rect2(0, 0, 100, 60), p => p + i.text({ text: 'Link', alignment: 'center' }, new Rect2(0, 0, 100, 60), p => p + .setBackgroundColor('#aaa') + .setStrokeColor(color) + .setTextDecoration('underline'), + ); + }) + } + /> + ); +}; + +export const TextMarkdown = () => { + return ( + + renderer.group(i => { + i.rectangle(1, 0, new Rect2(0, 0, 100, 60), p => p + .setStrokeColor('black'), + ); + i.text({ text: 'This is **bold**', alignment: 'center' }, new Rect2(0, 0, 100, 60), p => p .setBackgroundColor('#aaa') .setStrokeColor(color), + true, ); }) } diff --git a/src/wireframes/engine/shared.ts b/src/wireframes/engine/shared.ts index 8d5b92b..5bc3aa1 100644 --- a/src/wireframes/engine/shared.ts +++ b/src/wireframes/engine/shared.ts @@ -18,48 +18,6 @@ export const ROTATION_CONFIG = [ { angle: 315, cursor: 'nw-resize' }, ]; -export type PropertySet = Partial<{ - ['color']: T; - ['fill']: T; - ['font-family']: T; - ['font-size']: T; - ['image']: T; - ['markdown']: T; - ['opacity']: T; - ['preserve-aspect-ratio']: T; - ['radius']: T; - ['path']: T; - ['stroke']: T; - ['stroke-style']: T; - ['stroke-width']: T; - ['text']: T; - ['text-alignment']: T; - ['text-decoration']: T; - ['transform']: T; - ['vertical-alignment']: T; -}>; - -export const PROPERTIES: ReadonlyArray = [ - 'color', - 'fill', - 'font-family', - 'font-size', - 'image', - 'markdown', - 'opacity', - 'path', - 'preserve-aspect-ratio', - 'radius', - 'stroke-style', - 'stroke-width', - 'stroke', - 'text-alignment', - 'text-decoration', - 'text', - 'transform', - 'vertical-alignment', -]; - export function getBackgroundColor(value: RendererColor | null | undefined) { if (isShape(value)) { return value.backgroundColor; diff --git a/src/wireframes/engine/svg/canvas/SvgCanvas.tsx b/src/wireframes/engine/svg/canvas/SvgCanvas.tsx index 8013ce6..e03fcb7 100644 --- a/src/wireframes/engine/svg/canvas/SvgCanvas.tsx +++ b/src/wireframes/engine/svg/canvas/SvgCanvas.tsx @@ -10,7 +10,7 @@ import * as React from 'react'; import { CanvasProps } from '../../canvas'; import { SvgEngine } from './../engine'; -export const SvgCanvasView = (props: CanvasProps) => { +export const SvgCanvasView = (props: CanvasProps) => { const { className, onInit, diff --git a/src/wireframes/engine/svg/item.ts b/src/wireframes/engine/svg/item.ts index e3d4839..a361abd 100644 --- a/src/wireframes/engine/svg/item.ts +++ b/src/wireframes/engine/svg/item.ts @@ -18,6 +18,7 @@ export class SvgItem extends SvgObject implements EngineItem { private readonly group: svg.G; private readonly selector: svg.Rect; private currentShape: DiagramItem | null = null; + private currentRect: Rect2 | null = null; private isRendered = false; protected get root() { @@ -63,18 +64,26 @@ export class SvgItem extends SvgObject implements EngineItem { } } - private renderCore(item: DiagramItem) { - const localRect = new Rect2(0, 0, item.transform.size.x, item.transform.size.y); + private renderCore(shape: DiagramItem) { + const localRect = new Rect2(0, 0, shape.transform.size.x, shape.transform.size.y); + + if (this.currentRect && + this.currentRect.equals(localRect) && + this.currentShape?.appearance === shape.appearance) { + this.arrangeContainer(shape); + return; + } const previousContainer = this.renderer.getContainer(); try { this.renderer.setContainer(this.group, 1); - this.plugin.render({ renderer2: this.renderer, rect: localRect, shape: item }); + this.plugin.render({ renderer2: this.renderer, rect: localRect, shape: shape }); this.arrangeSelector(localRect); - this.arrangeContainer(item); + this.arrangeContainer(shape); } finally { + this.currentRect = localRect; this.renderer.cleanupAll(); this.renderer.setContainer(previousContainer); this.isRendered = true; diff --git a/src/wireframes/engine/svg/renderer.ts b/src/wireframes/engine/svg/renderer.ts index 6164bfe..3c88be0 100644 --- a/src/wireframes/engine/svg/renderer.ts +++ b/src/wireframes/engine/svg/renderer.ts @@ -9,12 +9,27 @@ import * as svg from '@svgdotjs/svg.js'; import { marked } from 'marked'; -import { Rect } from 'react-measure'; 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 { getBackgroundColor, getFontFamily, getFontSize, getForegroundColor, getOpacity, getStrokeColor, getStrokeWidth, getText, getTextAlignment, PROPERTIES, PropertySet } from './../shared'; +import { RendererColor, RendererOpacity, RendererText, RendererWidth, ShapeProperties, ShapePropertiesFunc, ShapeRenderer, ShapeRendererFunc, TextConfig, TextDecoration } from '@app/wireframes/interface'; +import { getBackgroundColor, getFontFamily, getFontSize, getForegroundColor, getOpacity, getStrokeColor, getStrokeWidth, getText, getTextAlignment } from './../shared'; import { SvgHelper } from './utils'; +const FACTORY_ELLIPSE = () => { + return new svg.Ellipse(); +}; + +const FACTORY_PATH = () => { + return new svg.Path(); +}; + +const FACTORY_RECT = () => { + return new svg.Rect(); +}; + +const FACTORY_TEXT = () => { + return SvgHelper.createText(); +}; + export class SvgRenderer implements ShapeRenderer { private containerItem: svg.G = null!; private containerIndex = 0; @@ -44,117 +59,107 @@ export class SvgRenderer implements ShapeRenderer { } public rectangle(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const actualStroke = getStrokeWidth(strokeWidth); - const actualBounds = getBounds(bounds, actualStroke); + const w = getStrokeWidth(strokeWidth); - return this.new('rect', () => new svg.Rect(), p => { - p.setBackgroundColor('transparent'); - p.setStrokeWidth(actualStroke); - p.setRadius(radius || 0); - p.setTransform(actualBounds); + return this.new('rect', FACTORY_RECT, { + bounds: getBounds(bounds, w), + radius, + strokeWidth: w, }, properties); } public ellipse(strokeWidth: RendererWidth, bounds: Rect2, properties?: ShapePropertiesFunc) { - const actualStroke = getStrokeWidth(strokeWidth); - const actualBounds = getBounds(bounds, actualStroke); + const w = getStrokeWidth(strokeWidth); - return this.new('ellipse', () => new svg.Ellipse(), p => { - p.setBackgroundColor('transparent'); - p.setStrokeWidth(actualStroke); - p.setTransform(actualBounds); + return this.new('ellipse', FACTORY_ELLIPSE, { + bounds: getBounds(bounds, w), + strokeWidth: w, }, properties); } public roundedRectangleLeft(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const actualStroke = getStrokeWidth(strokeWidth); - const actualBounds = getBounds(bounds, actualStroke); + const w = getStrokeWidth(strokeWidth); + const b = getBounds(bounds, w); - return this.new('path', () => new svg.Path(), p => { - p.setBackgroundColor('transparent'); - p.setStrokeWidth(actualStroke); - p.setPath(SvgHelper.roundedRectangleLeft(actualBounds, radius)); + return this.new('path', FACTORY_PATH, { + path: SvgHelper.roundedRectangleLeft(b, radius), + strokeWidth: w, }, properties); } public roundedRectangleRight(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const actualStroke = getStrokeWidth(strokeWidth); - const actualBounds = getBounds(bounds, actualStroke); + const w = getStrokeWidth(strokeWidth); + const b = getBounds(bounds, w); - return this.new('path', () => new svg.Path(), p => { - p.setBackgroundColor('transparent'); - p.setStrokeWidth(actualStroke); - p.setPath(SvgHelper.roundedRectangleRight(actualBounds, radius)); + return this.new('path', FACTORY_PATH, { + path: SvgHelper.roundedRectangleRight(b, radius), + strokeWidth: w, }, properties); } public roundedRectangleTop(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const actualStroke = getStrokeWidth(strokeWidth); - const actualBounds = getBounds(bounds, actualStroke); + const w = getStrokeWidth(strokeWidth); + const b = getBounds(bounds, w); - return this.new('path', () => new svg.Path(), p => { - p.setBackgroundColor('transparent'); - p.setStrokeWidth(actualStroke); - p.setPath(SvgHelper.roundedRectangleTop(actualBounds, radius)); + return this.new('path', FACTORY_PATH, { + path: SvgHelper.roundedRectangleTop(b, radius), + strokeWidth: w, }, properties); } public roundedRectangleBottom(strokeWidth: RendererWidth, radius: number, bounds: Rect2, properties?: ShapePropertiesFunc) { - const actualStroke = getStrokeWidth(strokeWidth); - const actualBounds = getBounds(bounds, actualStroke); + const w = getStrokeWidth(strokeWidth); + const b = getBounds(bounds, w); - return this.new('path', () => new svg.Path(), p => { - p.setBackgroundColor('transparent'); - p.setStrokeWidth(actualStroke); - p.setPath(SvgHelper.roundedRectangleBottom(actualBounds, radius)); + return this.new('path', FACTORY_PATH, { + path: SvgHelper.roundedRectangleBottom(b, radius), + strokeWidth: w, }, properties); } public path(strokeWidth: RendererWidth, path: string, properties?: ShapePropertiesFunc) { - const actualStroke = getStrokeWidth(strokeWidth); + const w = getStrokeWidth(strokeWidth); - return this.new('path', () => new svg.Path(), p => { - p.setBackgroundColor('transparent'); - p.setStrokeWidth(actualStroke); - p.setPath(path); + return this.new('path', FACTORY_PATH, { + path, + strokeWidth: w, }, properties); } public text(config: RendererText, bounds: Rect2, properties?: ShapePropertiesFunc, allowMarkdown?: boolean) { - return this.new('foreignObject', () => SvgHelper.createText('nowrap'), p => { - p.setBackgroundColor('transparent'); - p.setText(config?.text, allowMarkdown); - p.setFontSize(config); - p.setFontFamily(config); - p.setAlignment(config); - p.setVerticalAlignment('middle'); - p.setTransform(bounds); + return this.new('foreignObject', FACTORY_TEXT, { + whiteSpace: 'nowrap', + bounds, + fontFamily: getFontFamily(config), + fontSize: getFontSize(config), + textContent: { text: getText(config), markdown: allowMarkdown }, + textAlignment: getTextAlignment(config), + verticalAlignment: 'middle', }, properties); } public textMultiline(config: RendererText, bounds: Rect2, properties?: ShapePropertiesFunc, allowMarkdown?: boolean) { - return this.new('foreignObject', () => SvgHelper.createText('normal'), p => { - p.setBackgroundColor('transparent'); - p.setText(config?.text, allowMarkdown); - p.setFontSize(config); - p.setFontFamily(config); - p.setAlignment(config); - p.setVerticalAlignment('top'); - p.setTransform(bounds); + return this.new('foreignObject', FACTORY_TEXT, { + whiteSpace: 'normal', + bounds, + fontFamily: getFontFamily(config), + fontSize: getFontSize(config), + textContent: { text: getText(config), markdown: allowMarkdown }, + textAlignment: getTextAlignment(config), + verticalAlignment: 'top', }, properties); } public raster(source: string, bounds: Rect2, preserveAspectRatio?: boolean, properties?: ShapePropertiesFunc) { - return this.new('image', () => new svg.Image(), p => { - p.setBackgroundColor('transparent'); - p.setPreserveAspectValue(preserveAspectRatio); - p.setImage(source); - p.setTransform(bounds); + return this.new('image', () => new svg.Image(), { + bounds, + image: source, + preserveAspectRatio, }, properties); } public group(items: ShapeRendererFunc, clip?: ShapeRendererFunc, properties?: ShapePropertiesFunc) { - return this.new('g', () => new svg.G(), (_, group) => { + return this.new('g', () => new svg.G(), {}, properties, group => { const clipping = this.clipping; const container = this.containerItem; const containerIndex = this.containerIndex; @@ -178,18 +183,19 @@ export class SvgRenderer implements ShapeRenderer { this.containerItem = container; this.containerIndex = containerIndex; this.wasClipped = wasClipped; - }, properties); + }); } - private new(name: string, factory: () => T, defaults: (properties: Properties, element: T) => void, customProperties: ShapePropertiesFunc | undefined) { + private new(name: string, factory: () => T, defaults: Partial, + customProperties?: ShapePropertiesFunc, + customInitializer?: (element: T) => void) { + let element: T; if (this.wasClipped) { throw new Error('Only one clipping element is supported.'); } - const properties = Properties.INSTANCE; - if (this.clipping) { element = this.containerItem.clipper()?.get(0) as T; @@ -223,18 +229,16 @@ export class SvgRenderer implements ShapeRenderer { this.containerIndex++; } - properties.setElement(element); - - if (defaults) { - defaults(properties, element); - } + customInitializer?.(element); - if (customProperties) { - customProperties(properties); + const properties = PropertiesSetter.INSTANCE; + properties.prepare(defaults); + try { + customProperties?.(properties); + } finally { + properties.apply(element); } - properties.sync(); - return element; } @@ -258,84 +262,99 @@ export class SvgRenderer implements ShapeRenderer { } } -const ORDERED_PROPERTIES = [...PROPERTIES]; - -ORDERED_PROPERTIES.splice(ORDERED_PROPERTIES.indexOf('transform'), 1); -ORDERED_PROPERTIES.push('transform'); - -class Properties implements ShapeProperties { - private static readonly SETTERS: Record void> = { - 'color': (value, element) => { +export type Properties = { + bounds?: Rect2; + color?: string; + fill?: string; + fontFamily: string; + fontSize?: number; + image?: string | null; + opacity: number; + path?: string | null; + preserveAspectRatio?: boolean; + radius?: number; + stroke?: string; + strokeStyle?: { cap: string; join: string }; + strokeWidth?: number; + textContent?: { text: string; markdown?: boolean }; + textAlignment: string; + textDecoration: string; + verticalAlignment: string; + whiteSpace: string; +}; + +const DEFAULT_PROPERTIES: Properties = { + fill: 'transparent', + fontFamily: 'Arial', + fontSize: 16, + opacity: 1, + textAlignment: 'left', + textDecoration: 'none', + verticalAlignment: 'top', + whiteSpace: 'normal', +}; + +type Setters = { + [P in keyof T]?: (value: T[P], element: svg.Element, all: T) => void; +}; + +class PropertiesSetter implements ShapeProperties { + private static readonly SETTER_MAP: Setters = { + color: (value, element) => { SvgHelper.fastSetAttribute(element.node, 'color', value); }, - 'fill': (value, element) => { + fill: (value, element) => { SvgHelper.setAttribute(element.node, 'fill', value); }, - 'opacity': (value, element) => { + opacity: (value, element) => { SvgHelper.setAttribute(element.node, 'opacity', value); }, - 'preserve-aspect-ratio': (value, element) => { + preserveAspectRatio: (value, element) => { SvgHelper.setAttribute(element.node, 'preserveAspectRatio', value ? 'xMidYMid' : 'none'); }, - 'stroke': (value, element) => { + stroke: (value, element) => { SvgHelper.setAttribute(element.node, 'stroke', value); }, - 'stroke-style': (value, element) => { - SvgHelper.setAttribute(element.node, 'stroke-linecap', value.cap); - SvgHelper.setAttribute(element.node, 'stroke-linejoin', value.join); + strokeStyle: (value, element) => { + SvgHelper.setAttribute(element.node, 'stroke-linecap', value?.cap); + SvgHelper.setAttribute(element.node, 'stroke-linejoin', value?.join); }, - 'stroke-width': (value, element) => { + strokeWidth: (value, element) => { SvgHelper.setAttribute(element.node, 'stroke-width', value); }, - 'image': (value, element) => { + image: (value, element) => { SvgHelper.setAttribute(element.node, 'href', value); }, - 'path': (value, element) => { + path: (value, element) => { SvgHelper.setAttribute(element.node, 'd', value); }, - 'radius': (value, element) => { + radius: (value, element) => { SvgHelper.setAttribute(element.node, 'rx', value); SvgHelper.setAttribute(element.node, 'ry', value); }, - 'font-family': (value, element) => { + fontFamily: (value, element) => { const div = element.node.children[0] as HTMLDivElement; if (div?.nodeName === 'DIV') { div.style.fontFamily = value; } }, - 'font-size': (value, element) => { + fontSize: (value, element) => { const div = element.node.children[0] as HTMLDivElement; if (div?.nodeName === 'DIV') { div.style.fontSize = `${value}px`; } }, - 'markdown': (value, element) => { + textContent: (value, element) => { const div = element.node.children[0] as HTMLDivElement; if (div?.nodeName === 'DIV') { - const textOrHtml = marked.parseInline(value) as string; - - if (textOrHtml.indexOf('&') >= 0 || textOrHtml.indexOf('<') >= 0) { - div.innerHTML = textOrHtml; - } else { - div.innerText = textOrHtml; - } - } - }, - 'text': (value, element) => { - const div = element.node.children[0] as HTMLDivElement; - - - if (div?.nodeName === 'DIV') { - 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 (value?.markdown) { + textOrHtml = marked.parseInline(value.text) as string; + } else if (value?.text) { + textOrHtml = escapeHTML(value.text); } if (textOrHtml.indexOf('&') >= 0 || textOrHtml.indexOf('<') >= 0) { @@ -345,145 +364,121 @@ class Properties implements ShapeProperties { } } }, - 'text-alignment': (value, element) => { + textAlignment: (value, element) => { const div = element.node.children[0] as HTMLDivElement; if (div?.nodeName === 'DIV') { div.style.textAlign = value; } }, - 'text-decoration': (value, element) => { + textDecoration: (value, element) => { const div = element.node.children[0] as HTMLDivElement; if (div?.nodeName === 'DIV') { div.style.textDecoration = value; } }, - 'vertical-alignment': (value, element) => { + verticalAlignment: (value, element) => { const div = element.node.children[0] as HTMLDivElement; if (div?.nodeName === 'DIV') { div.style.verticalAlign = value; } }, - 'transform': (value, element) => { - SvgHelper.transformByRect(element, value, false); + whiteSpace: (value, element) => { + const div = element.node.children[0] as HTMLDivElement; + + if (div?.nodeName === 'DIV') { + div.style.whiteSpace = value; + } + }, + bounds: (value, element) => { + if (value) { + SvgHelper.transformByRect(element, value, false); + } }, }; - private propertiesNew: PropertySet = {}; - private propertiesOld: PropertySet = {}; - private element: svg.Element = null!; + public static readonly SETTERS = Object.entries(this.SETTER_MAP); + public static readonly INSTANCE = new PropertiesSetter(); - public static readonly INSTANCE = new Properties(); + private properties!: Properties; - public setElement(element: RendererElement) { - if (element.textElement) { - this.element = element.textElement; - } else { - this.element = element; + public prepare(defaults: Partial) { + const newProperties = { ...DEFAULT_PROPERTIES }; + Object.assign(newProperties, defaults); + + this.properties = newProperties; + } + + public apply(element: svg.Element) { + const oldProperties = (element.node as any)['properties'] || {} as any; + + for (const [property, setter] of PropertiesSetter.SETTERS) { + const value = (this.properties as any)[property]; + + if (!Types.equals(value, oldProperties[property])) { + setter(value as never, element, this.properties); + } } - this.propertiesNew = {}; - this.propertiesOld = (this.element.node as any)['properties'] || {}; + (element.node as any)['properties'] = this.properties; } public setBackgroundColor(color: RendererColor | null | undefined): ShapeProperties { - this.propertiesNew['fill'] = SvgHelper.toColor(getBackgroundColor(color)); + this.properties.fill = SvgHelper.toColor(getBackgroundColor(color)); return this; } public setForegroundColor(color: RendererColor | null | undefined): ShapeProperties { - this.propertiesNew['color'] = SvgHelper.toColor(getForegroundColor(color)); + this.properties.color = SvgHelper.toColor(getForegroundColor(color)); return this; } public setStrokeColor(color: RendererColor | null | undefined): ShapeProperties { - this.propertiesNew['stroke'] = SvgHelper.toColor(getStrokeColor(color)); + this.properties.stroke = SvgHelper.toColor(getStrokeColor(color)); return this; } public setStrokeWidth(width: number): ShapeProperties { - this.propertiesNew['stroke-width'] = width; - return this; - } - - public setPath(path: string | null | undefined): ShapeProperties { - this.propertiesNew['path'] = path; - return this; - } - - public setPreserveAspectValue(value: boolean | null | undefined): ShapeProperties { - this.propertiesNew['preserve-aspect-ratio'] = value; - return this; - } - - public setRadius(radius: number | null | undefined): ShapeProperties { - this.propertiesNew['radius'] = radius || 0; - return this; - } - - public setTransform(rect: Rect | null | undefined): ShapeProperties { - this.propertiesNew['transform'] = rect; + this.properties.strokeWidth = width; return this; } - public setImage(source: string | null | undefined): ShapeProperties { - this.propertiesNew['image'] = source; - return this; - } - - public setFontSize(fontSize: TextConfig | Shape | number | null | undefined): ShapeProperties { - this.propertiesNew['font-size'] = getFontSize(fontSize); + public setFontFamily(fontFamily: TextConfig | string | null | undefined): ShapeProperties { + this.properties.fontFamily = getFontFamily(fontFamily); return this; } - public setFontFamily(fontFamily: TextConfig | string | null | undefined): ShapeProperties { - this.propertiesNew['font-family'] = getFontFamily(fontFamily); + public setFontSize(fontSize: TextConfig | number | null | undefined): ShapeProperties { + this.properties.fontSize = getFontSize(fontSize); return this; } public setAlignment(alignment: TextConfig | null | undefined): ShapeProperties { - this.propertiesNew['text-alignment'] = getTextAlignment(alignment); + this.properties.textAlignment = getTextAlignment(alignment); return this; } public setTextDecoration(decoration: TextDecoration): ShapeProperties { - this.propertiesNew['text-decoration'] = decoration; - return this; - } - - public setVerticalAlignment(alignment: string | null | undefined): ShapeProperties { - this.propertiesNew['vertical-alignment'] = alignment; + this.properties.textDecoration = decoration; return this; } public setText(text: RendererText | string | null | undefined, markdown?: boolean): ShapeProperties { - this.propertiesNew['text'] = { text: getText(text), markdown }; + this.properties.textContent = { text: getText(text), markdown }; return this; } public setStrokeStyle(cap: string, join: string): ShapeProperties { - this.propertiesNew['stroke-style'] = { cap, join }; + this.properties.strokeStyle = { cap, join }; return this; } public setOpacity(opacity: RendererOpacity | null | undefined): ShapeProperties { - this.propertiesNew.opacity = getOpacity(opacity); + this.properties.opacity = getOpacity(opacity); return this; } - - public sync() { - for (const key of ORDERED_PROPERTIES) { - const value = this.propertiesNew[key]; - - if (!Types.equals(value, this.propertiesOld[key])) { - Properties.SETTERS[key](value, this.element); - } - } - - (this.element.node as any)['properties'] = this.propertiesNew; - } } function getBounds(bounds: Rect2, strokeWidth: number) { diff --git a/src/wireframes/engine/svg/utils.ts b/src/wireframes/engine/svg/utils.ts index b266dbe..29c41b6 100644 --- a/src/wireframes/engine/svg/utils.ts +++ b/src/wireframes/engine/svg/utils.ts @@ -84,7 +84,7 @@ export module SvgHelper { return `M${r},${t} L${r},${b - rad} a${rad},${rad} 0 0 1 -${rad},${rad} L${l + rad},${b} a${rad},${rad} 0 0 1 -${rad},-${rad} L${l},${t}z`; } - export function createText(whitespace: string, text?: string, fontSize?: number, alignment?: string, verticalAlign?: string) { + export function createText(whitespace?: string, text?: string, fontSize?: number, alignment?: string, verticalAlign?: string) { const element = new svg.ForeignObject(); const div = document.createElement('div'); div.className = 'no-select'; @@ -93,7 +93,7 @@ export module SvgHelper { div.style.fontSize = sizeInPx(fontSize || 10); div.style.overflow = 'hidden'; div.style.verticalAlign = verticalAlign || 'middle'; - div.style.whiteSpace = whitespace; + div.style.whiteSpace = whitespace || 'normal'; div.textContent = text || null; element.node.appendChild(div); diff --git a/src/wireframes/interface/index.ts b/src/wireframes/interface/index.ts index ef4be83..4b868b7 100644 --- a/src/wireframes/interface/index.ts +++ b/src/wireframes/interface/index.ts @@ -57,6 +57,8 @@ export interface ShapeProperties { setStrokeStyle(cap: string, join: string): ShapeProperties; + setFontSize(fontSize: RendererText | number): ShapeProperties; + setFontFamily(fontFamily: RendererText | string): ShapeProperties; setOpacity(opacity: RendererOpacity): ShapeProperties;