diff --git a/README.md b/README.md index e2ffd8a..fb1b49c 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,31 @@ Visit: https://react.nodegui.org for docs. - Read through the [docs](https://react.nodegui.org) **Community Guides** + - https://gregbenner.life/node-gui-react-component-by-component/ - An awesome intro to all base components in react nodegui - https://blog.logrocket.com/electron-alternatives-exploring-nodegui-and-react-nodegui/ - Electron alternatives: Exploring NodeGUI and React NodeGUI by [Siegfried Grimbeek](https://blog.logrocket.com/author/siegfriedgrimbeek/). +## SVG + +React NodeGUI supports rendering simple SVG trees by serializing SVG-like React components and rendering them as an image. + +```jsx +import React from "react"; +import { Renderer, Svg, Rect, Circle, Window } from "@nodegui/react-nodegui"; + +const App = () => ( + + + + + + +); + +Renderer.render(); +``` + **Talks/Podcasts** - [NodeGui and React NodeGui at KarmaJS Nov 2019 meetup: https://www.youtube.com/watch?v=8jH5gaEEDv4](https://www.youtube.com/watch?v=8jH5gaEEDv4) diff --git a/src/components/Svg/RNSvg.ts b/src/components/Svg/RNSvg.ts new file mode 100644 index 0000000..c26e60f --- /dev/null +++ b/src/components/Svg/RNSvg.ts @@ -0,0 +1,331 @@ +import { QLabel, QPixmap, QSize } from "@nodegui/nodegui"; +import { Component } from "@nodegui/nodegui/dist/lib/core/Component"; +import { RNComponent, RNProps } from "../config"; +import { TextProps, setTextProps } from "../Text/RNText"; + +export type SvgValue = string | number | boolean | undefined | null; + +export interface SvgProps extends TextProps { + width?: number | string; + height?: number | string; + viewBox?: string; + preserveAspectRatio?: string; + xmlns?: string; + fill?: SvgValue; + stroke?: SvgValue; + strokeWidth?: SvgValue; + style?: string; +} + +export interface SvgNodeProps extends RNProps { + fill?: SvgValue; + stroke?: SvgValue; + strokeWidth?: SvgValue; + opacity?: SvgValue; + transform?: string; + style?: string; + id?: string; + className?: string; +} + +export interface SvgRectProps extends SvgNodeProps { + x?: SvgValue; + y?: SvgValue; + width?: SvgValue; + height?: SvgValue; + rx?: SvgValue; + ry?: SvgValue; +} + +export interface SvgCircleProps extends SvgNodeProps { + cx?: SvgValue; + cy?: SvgValue; + r?: SvgValue; +} + +export interface SvgEllipseProps extends SvgNodeProps { + cx?: SvgValue; + cy?: SvgValue; + rx?: SvgValue; + ry?: SvgValue; +} + +export interface SvgLineProps extends SvgNodeProps { + x1?: SvgValue; + y1?: SvgValue; + x2?: SvgValue; + y2?: SvgValue; +} + +export interface SvgPolygonProps extends SvgNodeProps { + points?: string; +} + +export interface SvgPathProps extends SvgNodeProps { + d?: string; +} + +const svgNamespace = "http://www.w3.org/2000/svg"; + +const svgAttributeMap: { [key: string]: string } = { + className: "class", + preserveAspectRatio: "preserveAspectRatio", + strokeWidth: "stroke-width", +}; + +const escapeXml = (value: SvgValue): string => + String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +const serializeAttributes = (props: { [key: string]: SvgValue }): string => + Object.keys(props) + .filter( + (key) => + props[key] !== undefined && props[key] !== null && props[key] !== false + ) + .map((key) => { + const attributeName = svgAttributeMap[key] || key; + return `${attributeName}="${escapeXml(props[key])}"`; + }) + .join(" "); + +const serializeElement = ( + tagName: string, + props: { [key: string]: SvgValue }, + children: RNSvgNode[] = [] +): string => { + const attributes = serializeAttributes(props); + const openTag = attributes ? `<${tagName} ${attributes}` : `<${tagName}`; + if (!children.length) { + return `${openTag} />`; + } + return `${openTag}>${children + .map((child) => child.toSvgString()) + .join("")}`; +}; + +const isSvgNode = (child: Component): child is RNSvgNode => + child instanceof RNSvgNode; + +export abstract class RNSvgNode extends Component implements RNComponent { + static tagName: string; + protected props: SvgNodeProps = {}; + protected svgChildren: RNSvgNode[] = []; + private parent: RNSvgNode | RNSvg | null = null; + + constructor() { + super({ type: "native" }); + } + + setProps(newProps: SvgNodeProps, oldProps: SvgNodeProps): void { + this.props = newProps; + this.requestRender(); + } + + appendInitialChild(child: Component): void { + this.appendChild(child); + } + + appendChild(child: Component): void { + if (!isSvgNode(child)) { + return; + } + this.svgChildren.push(child); + child.setSvgParent(this); + this.requestRender(); + } + + insertBefore(child: Component, beforeChild: Component): void { + if (!isSvgNode(child) || !isSvgNode(beforeChild)) { + return; + } + const index = this.svgChildren.indexOf(beforeChild); + if (index === -1) { + this.appendChild(child); + return; + } + this.svgChildren.splice(index, 0, child); + child.setSvgParent(this); + this.requestRender(); + } + + removeChild(child: Component): void { + if (!isSvgNode(child)) { + return; + } + this.svgChildren = this.svgChildren.filter( + (existingChild) => existingChild !== child + ); + child.setSvgParent(null); + this.requestRender(); + } + + setSvgParent(parent: RNSvgNode | RNSvg | null): void { + this.parent = parent; + } + + requestRender(): void { + if (this.parent) { + this.parent.requestRender(); + } + } + + abstract toSvgString(): string; +} + +export class RNSvg extends QLabel implements RNComponent { + static tagName = "svg"; + private props: SvgProps = {}; + private svgChildren: RNSvgNode[] = []; + + setProps(newProps: SvgProps, oldProps: SvgProps): void { + this.props = newProps; + setTextProps(this as any, newProps, oldProps); + this.renderSvg(); + } + + appendInitialChild(child: Component): void { + this.appendChild(child); + } + + appendChild(child: Component): void { + if (!isSvgNode(child)) { + return; + } + this.svgChildren.push(child); + child.setSvgParent(this); + this.renderSvg(); + } + + insertBefore(child: Component, beforeChild: Component): void { + if (!isSvgNode(child) || !isSvgNode(beforeChild)) { + return; + } + const index = this.svgChildren.indexOf(beforeChild); + if (index === -1) { + this.appendChild(child); + return; + } + this.svgChildren.splice(index, 0, child); + child.setSvgParent(this); + this.renderSvg(); + } + + removeChild(child: Component): void { + if (!isSvgNode(child)) { + return; + } + this.svgChildren = this.svgChildren.filter( + (existingChild) => existingChild !== child + ); + child.setSvgParent(null); + this.renderSvg(); + } + + requestRender(): void { + this.renderSvg(); + } + + scalePixmap(size: QSize): void { + this.renderSvg(size.width(), size.height()); + } + + private renderSvg( + width = this.size().width(), + height = this.size().height() + ): void { + const pixmap = new QPixmap(); + pixmap.loadFromData(Buffer.from(this.toSvgString(width, height))); + this.setPixmap(pixmap); + } + + private toSvgString(renderWidth: number, renderHeight: number): string { + const width = this.props.width || renderWidth || 100; + const height = this.props.height || renderHeight || 100; + const attributes = serializeAttributes({ + fill: this.props.fill, + height, + preserveAspectRatio: this.props.preserveAspectRatio, + stroke: this.props.stroke, + strokeWidth: this.props.strokeWidth, + style: this.props.style, + viewBox: this.props.viewBox, + width, + xmlns: this.props.xmlns || svgNamespace, + }); + return `${this.svgChildren + .map((child) => child.toSvgString()) + .join("")}`; + } +} + +export class RNSvgGroup extends RNSvgNode { + static tagName = "g"; + toSvgString(): string { + return serializeElement( + "g", + this.props as { [key: string]: SvgValue }, + this.svgChildren + ); + } +} + +export class RNSvgRect extends RNSvgNode { + static tagName = "rect"; + protected props: SvgRectProps = {}; + toSvgString(): string { + return serializeElement("rect", this.props as { [key: string]: SvgValue }); + } +} + +export class RNSvgCircle extends RNSvgNode { + static tagName = "circle"; + protected props: SvgCircleProps = {}; + toSvgString(): string { + return serializeElement( + "circle", + this.props as { [key: string]: SvgValue } + ); + } +} + +export class RNSvgEllipse extends RNSvgNode { + static tagName = "ellipse"; + protected props: SvgEllipseProps = {}; + toSvgString(): string { + return serializeElement( + "ellipse", + this.props as { [key: string]: SvgValue } + ); + } +} + +export class RNSvgLine extends RNSvgNode { + static tagName = "line"; + protected props: SvgLineProps = {}; + toSvgString(): string { + return serializeElement("line", this.props as { [key: string]: SvgValue }); + } +} + +export class RNSvgPolygon extends RNSvgNode { + static tagName = "polygon"; + protected props: SvgPolygonProps = {}; + toSvgString(): string { + return serializeElement( + "polygon", + this.props as { [key: string]: SvgValue } + ); + } +} + +export class RNSvgPath extends RNSvgNode { + static tagName = "path"; + protected props: SvgPathProps = {}; + toSvgString(): string { + return serializeElement("path", this.props as { [key: string]: SvgValue }); + } +} diff --git a/src/components/Svg/index.ts b/src/components/Svg/index.ts new file mode 100644 index 0000000..1449440 --- /dev/null +++ b/src/components/Svg/index.ts @@ -0,0 +1,112 @@ +import { WidgetEventTypes } from "@nodegui/nodegui"; +import { Fiber } from "react-reconciler"; +import { AppContainer } from "../../reconciler"; +import { ComponentConfig, RNProps, registerComponent } from "../config"; +import { + RNSvg, + RNSvgCircle, + RNSvgEllipse, + RNSvgGroup, + RNSvgLine, + RNSvgNode, + RNSvgPath, + RNSvgPolygon, + RNSvgRect, + SvgCircleProps, + SvgEllipseProps, + SvgLineProps, + SvgNodeProps, + SvgPathProps, + SvgPolygonProps, + SvgProps, + SvgRectProps, +} from "./RNSvg"; + +type SvgComponentFactory = new () => RNSvgNode; + +class SvgConfig extends ComponentConfig { + tagName = RNSvg.tagName; + shouldSetTextContent(): boolean { + return false; + } + createInstance( + newProps: SvgProps, + rootInstance: AppContainer, + context: any, + workInProgress: Fiber + ): RNSvg { + const widget = new RNSvg(); + widget.setProperty("scaledContents", true); + widget.setProps(newProps, {}); + widget.addEventListener(WidgetEventTypes.Resize, () => { + widget.scalePixmap(widget.size()); + }); + return widget; + } + commitMount(instance: RNSvg, newProps: SvgProps): void { + if (newProps.visible !== false) { + instance.show(); + } + } + commitUpdate( + instance: RNSvg, + updatePayload: any, + oldProps: SvgProps, + newProps: SvgProps, + finishedWork: Fiber + ): void { + instance.setProps(newProps, oldProps); + } +} + +class SvgNodeConfig extends ComponentConfig { + constructor(public tagName: string, private NodeClass: SvgComponentFactory) { + super(); + } + shouldSetTextContent(): boolean { + return false; + } + createInstance( + newProps: T, + rootInstance: AppContainer, + context: any, + workInProgress: Fiber + ): RNSvgNode { + const node = new this.NodeClass(); + node.setProps(newProps as SvgNodeProps, {}); + return node; + } + commitUpdate( + instance: RNSvgNode, + updatePayload: any, + oldProps: T, + newProps: T, + finishedWork: Fiber + ): void { + instance.setProps(newProps as SvgNodeProps, oldProps as SvgNodeProps); + } +} + +export const Svg = registerComponent(new SvgConfig()); +export const G = registerComponent( + new SvgNodeConfig(RNSvgGroup.tagName, RNSvgGroup) +); +export const Group = G; +export const Rect = registerComponent( + new SvgNodeConfig(RNSvgRect.tagName, RNSvgRect) +); +export const Circle = registerComponent( + new SvgNodeConfig(RNSvgCircle.tagName, RNSvgCircle) +); +export const Ellipse = registerComponent( + new SvgNodeConfig(RNSvgEllipse.tagName, RNSvgEllipse) +); +export const Line = registerComponent( + new SvgNodeConfig(RNSvgLine.tagName, RNSvgLine) +); +export const Polygon = registerComponent( + new SvgNodeConfig(RNSvgPolygon.tagName, RNSvgPolygon) +); +export const Path = registerComponent( + new SvgNodeConfig(RNSvgPath.tagName, RNSvgPath) +); diff --git a/src/index.ts b/src/index.ts index 78e18b8..c5ad56a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,17 @@ export { Window } from "./components/Window"; export { Text } from "./components/Text"; export { Image } from "./components/Image"; export { AnimatedImage } from "./components/AnimatedImage"; +export { + Svg, + G, + Group, + Rect, + Circle, + Ellipse, + Line, + Polygon, + Path, +} from "./components/Svg"; export { Button } from "./components/Button"; export { CheckBox } from "./components/CheckBox"; export { LineEdit } from "./components/LineEdit";