diff --git a/README.md b/README.md index e2ffd8ad..fc24f799 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,32 @@ Visit: https://react.nodegui.org for docs. - 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 can render inline SVG trees by serializing SVG-like React components and rendering the result with Qt's SVG widget. + +```jsx +import React from "react"; +import { Renderer, Svg, Rect, Circle, Path, Window } from "@nodegui/react-nodegui"; + +const App = () => ( + + + + + + + +); + +Renderer.render(); +``` + +For complete SVG documents, pass `src`, `buffer`, or `content` to `Svg`. + +Most inline SVG primitives can be used as lowercase JSX tags or via the exported helpers like `Rect` and `Circle`. +Use `SvgText` for SVG text nodes. + **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) @@ -76,6 +102,10 @@ Please read: https://github.com/nodegui/.github/blob/master/CONTRIBUTING.md `npm run build` +## Verification + +`npm run verify:svg` + ## Using custom Qt `QT_INSTALL_DIR=/path/to/qt npm install` diff --git a/package.json b/package.json index b7abff71..d739e104 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "scripts": { "build": "tsc", "dev": "tsc && qode ./dist/demo.js", - "docs": "typedoc && node ./website/docs/scripts/fixdocs.js" + "docs": "typedoc && node ./website/docs/scripts/fixdocs.js", + "verify:svg": "npm run build && qode ./dist/development/svg-acceptance.js" }, "dependencies": { "@nodegui/nodegui": "^0.57.1", diff --git a/src/components/Svg/RNSvg.ts b/src/components/Svg/RNSvg.ts new file mode 100644 index 00000000..8f42815b --- /dev/null +++ b/src/components/Svg/RNSvg.ts @@ -0,0 +1,658 @@ +import { + QSvgWidget, + QWidgetSignals, +} from "@nodegui/nodegui"; +import { RNComponent, RNProps, RNWidget } from "../config"; +import { ViewProps, setViewProps } from "../View/RNView"; + +type SvgPrimitive = string | number | boolean; +type SvgStyle = string | Record; +type SvgPropValue = + | SvgPrimitive + | SvgStyle + | null + | undefined + | Record; + +export interface SvgProps extends ViewProps { + src?: string; + buffer?: Buffer; + content?: string; + children?: unknown; + width?: SvgPrimitive; + height?: SvgPrimitive; + viewBox?: string; + preserveAspectRatio?: string; + [attribute: string]: SvgPropValue | unknown; +} + +export interface SvgElementProps extends RNProps { + children?: unknown; + style?: SvgStyle; + id?: string; + className?: string; + fill?: SvgPrimitive; + stroke?: SvgPrimitive; + strokeWidth?: SvgPrimitive; + opacity?: SvgPrimitive; + transform?: string; + [attribute: string]: SvgPropValue | unknown; +} + +export interface SvgRectProps extends SvgElementProps { + x?: SvgPrimitive; + y?: SvgPrimitive; + width?: SvgPrimitive; + height?: SvgPrimitive; + rx?: SvgPrimitive; + ry?: SvgPrimitive; +} + +export interface SvgCircleProps extends SvgElementProps { + cx?: SvgPrimitive; + cy?: SvgPrimitive; + r?: SvgPrimitive; +} + +export interface SvgEllipseProps extends SvgElementProps { + cx?: SvgPrimitive; + cy?: SvgPrimitive; + rx?: SvgPrimitive; + ry?: SvgPrimitive; +} + +export interface SvgLineProps extends SvgElementProps { + x1?: SvgPrimitive; + y1?: SvgPrimitive; + x2?: SvgPrimitive; + y2?: SvgPrimitive; +} + +export interface SvgPolygonProps extends SvgElementProps { + points?: string; +} + +export interface SvgPolylineProps extends SvgElementProps { + points?: string; +} + +export interface SvgPathProps extends SvgElementProps { + d?: string; +} + +export interface SvgTextProps extends SvgElementProps { + x?: SvgPrimitive; + y?: SvgPrimitive; + dx?: SvgPrimitive; + dy?: SvgPrimitive; + textAnchor?: string; + fontFamily?: string; + fontSize?: SvgPrimitive; + fontWeight?: SvgPrimitive; +} + +type DangerousHtml = { + __html?: string; +}; + +export type SvgParent = RNSvg | RNSvgElement; + +export interface SvgRenderable { + setSvgParent(parent: SvgParent | null): void; + toSvgString(): string; +} + +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + +const WIDGET_PROP_NAMES = new Set([ + "visible", + "styleSheet", + "geometry", + "id", + "mouseTracking", + "enabled", + "windowOpacity", + "windowTitle", + "windowState", + "cursor", + "windowIcon", + "minSize", + "maxSize", + "size", + "pos", + "on", + "attributes", + "windowFlags", +]); + +const IGNORED_SVG_PROPS = new Set([ + "children", + "key", + "ref", + "__self", + "__source", + "src", + "buffer", + "content", + "dangerouslySetInnerHTML", + "visible", + "styleSheet", + "geometry", + "mouseTracking", + "enabled", + "windowOpacity", + "windowTitle", + "windowState", + "cursor", + "windowIcon", + "minSize", + "maxSize", + "size", + "pos", + "on", + "attributes", + "windowFlags", +]); + +const SVG_ATTRIBUTE_NAMES: Record = { + acceptCharset: "accept-charset", + accentHeight: "accent-height", + alignmentBaseline: "alignment-baseline", + arabicForm: "arabic-form", + baselineShift: "baseline-shift", + capHeight: "cap-height", + className: "class", + clipPath: "clip-path", + clipRule: "clip-rule", + colorInterpolation: "color-interpolation", + colorInterpolationFilters: "color-interpolation-filters", + dominantBaseline: "dominant-baseline", + enableBackground: "enable-background", + fillOpacity: "fill-opacity", + fillRule: "fill-rule", + floodColor: "flood-color", + floodOpacity: "flood-opacity", + fontFamily: "font-family", + fontSize: "font-size", + fontSizeAdjust: "font-size-adjust", + fontStretch: "font-stretch", + fontStyle: "font-style", + fontVariant: "font-variant", + fontWeight: "font-weight", + glyphName: "glyph-name", + horizAdvX: "horiz-adv-x", + horizOriginX: "horiz-origin-x", + imageRendering: "image-rendering", + letterSpacing: "letter-spacing", + lightingColor: "lighting-color", + markerEnd: "marker-end", + markerMid: "marker-mid", + markerStart: "marker-start", + overlinePosition: "overline-position", + overlineThickness: "overline-thickness", + paintOrder: "paint-order", + pointerEvents: "pointer-events", + shapeRendering: "shape-rendering", + stopColor: "stop-color", + stopOpacity: "stop-opacity", + strikethroughPosition: "strikethrough-position", + strikethroughThickness: "strikethrough-thickness", + strokeDasharray: "stroke-dasharray", + strokeDashoffset: "stroke-dashoffset", + strokeLinecap: "stroke-linecap", + strokeLinejoin: "stroke-linejoin", + strokeMiterlimit: "stroke-miterlimit", + strokeOpacity: "stroke-opacity", + strokeWidth: "stroke-width", + textAnchor: "text-anchor", + textDecoration: "text-decoration", + textRendering: "text-rendering", + underlinePosition: "underline-position", + underlineThickness: "underline-thickness", + unicodeBidi: "unicode-bidi", + unicodeRange: "unicode-range", + vectorEffect: "vector-effect", + vertAdvY: "vert-adv-y", + vertOriginX: "vert-origin-x", + vertOriginY: "vert-origin-y", + wordSpacing: "word-spacing", + writingMode: "writing-mode", + xHeight: "x-height", + xlinkActuate: "xlink:actuate", + xlinkArcrole: "xlink:arcrole", + xlinkHref: "xlink:href", + xlinkRole: "xlink:role", + xlinkShow: "xlink:show", + xlinkTitle: "xlink:title", + xlinkType: "xlink:type", + xmlBase: "xml:base", + xmlLang: "xml:lang", + xmlSpace: "xml:space", +}; + +const CASE_SENSITIVE_SVG_ATTRIBUTES = new Set([ + "attributeName", + "baseFrequency", + "calcMode", + "clipPathUnits", + "diffuseConstant", + "edgeMode", + "filterUnits", + "gradientTransform", + "gradientUnits", + "kernelMatrix", + "kernelUnitLength", + "keyPoints", + "keySplines", + "keyTimes", + "lengthAdjust", + "limitingConeAngle", + "markerHeight", + "markerUnits", + "markerWidth", + "maskContentUnits", + "maskUnits", + "numOctaves", + "pathLength", + "patternContentUnits", + "patternTransform", + "patternUnits", + "pointsAtX", + "pointsAtY", + "pointsAtZ", + "preserveAlpha", + "preserveAspectRatio", + "primitiveUnits", + "refX", + "refY", + "repeatCount", + "repeatDur", + "requiredExtensions", + "requiredFeatures", + "specularConstant", + "specularExponent", + "spreadMethod", + "startOffset", + "stdDeviation", + "surfaceScale", + "systemLanguage", + "tableValues", + "targetX", + "targetY", + "viewBox", + "viewTarget", +]); + +/** + * @ignore + */ +export class RNSvg extends QSvgWidget implements RNWidget { + static tagName = "svg"; + private props: SvgProps = {}; + private svgChildren: SvgRenderable[] = []; + + setProps(newProps: SvgProps, oldProps: SvgProps): void { + this.props = newProps; + + setViewProps(this, getWidgetProps(newProps), getWidgetProps(oldProps)); + this.renderSvg(); + } + + appendInitialChild(child: any): void { + this.appendChild(child); + } + + appendChild(child: any): void { + if (!isSvgRenderable(child)) { + return; + } + + child.setSvgParent(this); + this.svgChildren = withoutSvgChild(this.svgChildren, child); + this.svgChildren.push(child); + this.renderSvg(); + } + + insertBefore(child: any, beforeChild: any): void { + if (!isSvgRenderable(child)) { + return; + } + + child.setSvgParent(this); + const nextChildren = withoutSvgChild(this.svgChildren, child); + const childIndex = nextChildren.indexOf(beforeChild); + + if (childIndex === -1) { + nextChildren.push(child); + } else { + nextChildren.splice(childIndex, 0, child); + } + + this.svgChildren = nextChildren; + this.renderSvg(); + } + + removeChild(child: any): void { + const childIndex = this.svgChildren.indexOf(child); + + if (childIndex === -1) { + return; + } + + child.setSvgParent(null); + this.svgChildren.splice(childIndex, 1); + this.renderSvg(); + } + + requestRender(): void { + this.renderSvg(); + } + + toSvgString(): string { + return serializeElement("svg", this.props, this.svgChildren); + } + + private renderSvg(): void { + if (this.props.buffer instanceof Buffer) { + this.load(this.props.buffer); + return; + } + + if (typeof this.props.src === "string" && this.props.src) { + this.load(this.props.src); + return; + } + + const svg = typeof this.props.content === "string" + ? this.props.content + : this.toSvgString(); + + this.load(Buffer.from(svg)); + } +} + +/** + * @ignore + */ +export class RNSvgElement implements RNComponent, SvgRenderable { + private props: SvgElementProps = {}; + private svgChildren: SvgRenderable[] = []; + private svgParent: SvgParent | null = null; + + constructor(private readonly svgTagName: string) { + } + + setProps(newProps: SvgElementProps, _oldProps: SvgElementProps): void { + this.props = newProps; + this.requestRender(); + } + + appendInitialChild(child: any): void { + this.appendChild(child); + } + + appendChild(child: any): void { + if (!isSvgRenderable(child)) { + return; + } + + child.setSvgParent(this); + this.svgChildren = withoutSvgChild(this.svgChildren, child); + this.svgChildren.push(child); + this.requestRender(); + } + + insertBefore(child: any, beforeChild: any): void { + if (!isSvgRenderable(child)) { + return; + } + + child.setSvgParent(this); + const nextChildren = withoutSvgChild(this.svgChildren, child); + const childIndex = nextChildren.indexOf(beforeChild); + + if (childIndex === -1) { + nextChildren.push(child); + } else { + nextChildren.splice(childIndex, 0, child); + } + + this.svgChildren = nextChildren; + this.requestRender(); + } + + removeChild(child: any): void { + const childIndex = this.svgChildren.indexOf(child); + + if (childIndex === -1) { + return; + } + + child.setSvgParent(null); + this.svgChildren.splice(childIndex, 1); + this.requestRender(); + } + + setSvgParent(parent: SvgParent | null): void { + this.svgParent = parent; + } + + requestRender(): void { + if (this.svgParent) { + this.svgParent.requestRender(); + } + } + + toSvgString(): string { + return serializeElement(this.svgTagName, this.props, this.svgChildren); + } +} + +export class RNSvgTextNode implements RNComponent, SvgRenderable { + private svgParent: SvgParent | null = null; + + constructor(private text: string) { + } + + setProps(newProps: RNProps, _oldProps: RNProps): void { + this.text = String((newProps as { text?: string }).text ?? ""); + this.requestRender(); + } + + setText(text: string): void { + this.text = text; + this.requestRender(); + } + + appendInitialChild(): void { + throw new Error("SVG text nodes cannot have children"); + } + + appendChild(): void { + throw new Error("SVG text nodes cannot have children"); + } + + insertBefore(): void { + throw new Error("SVG text nodes cannot have children"); + } + + removeChild(): void { + throw new Error("SVG text nodes cannot have children"); + } + + setSvgParent(parent: SvgParent | null): void { + this.svgParent = parent; + } + + requestRender(): void { + if (this.svgParent) { + this.svgParent.requestRender(); + } + } + + toSvgString(): string { + return escapeText(this.text); + } +} + +export function createSvgElement(svgTagName: string, props: SvgElementProps): RNSvgElement { + const element = new RNSvgElement(svgTagName); + element.setProps(props, {}); + return element; +} + +export function createSvgTextNode(text: string): RNSvgTextNode { + return new RNSvgTextNode(text); +} + +function getWidgetProps(props: SvgProps): ViewProps { + return Object.keys(props || {}).reduce((widgetProps, key) => { + if (WIDGET_PROP_NAMES.has(key)) { + (widgetProps as Record)[key] = props[key]; + } + + return widgetProps; + }, {} as ViewProps); +} + +function isSvgRenderable(value: unknown): value is SvgRenderable { + return ( + value instanceof RNSvgElement + || value instanceof RNSvgTextNode + ); +} + +function withoutSvgChild( + children: SvgRenderable[], + child: SvgRenderable +): SvgRenderable[] { + return children.filter((existingChild) => existingChild !== child); +} + +function serializeElement( + tagName: string, + props: SvgProps | SvgElementProps, + children: SvgRenderable[] +): string { + const attributes = serializeAttributes(tagName, props); + const innerSvg = serializeInnerSvg(props, children); + + if (innerSvg) { + return `<${tagName}${attributes}>${innerSvg}`; + } + + return `<${tagName}${attributes}/>`; +} + +function serializeInnerSvg( + props: SvgProps | SvgElementProps, + children: SvgRenderable[] +): string { + const dangerousHtml = props.dangerouslySetInnerHTML as DangerousHtml | undefined; + + if (typeof dangerousHtml?.__html === "string") { + return dangerousHtml.__html; + } + + return [ + serializeTextChildren(props.children), + ...children.map(child => child.toSvgString()), + ].join(""); +} + +function serializeTextChildren(children: unknown): string { + if (typeof children === "string" || typeof children === "number") { + return escapeText(children); + } + + if (Array.isArray(children)) { + const hasOnlyPrimitiveChildren = children.every( + (child) => typeof child === "string" || typeof child === "number" + ); + + if (!hasOnlyPrimitiveChildren) { + return ""; + } + + return children + .filter(child => typeof child === "string" || typeof child === "number") + .map(child => escapeText(child as string | number)) + .join(""); + } + + return ""; +} + +function serializeAttributes(tagName: string, props: SvgProps | SvgElementProps): string { + const attributes = Object.keys(props || {}).reduce((result, key) => { + if (IGNORED_SVG_PROPS.has(key) || key.startsWith("on")) { + return result; + } + + const value = (props as Record)[key]; + + if (value === null || value === undefined || value === false) { + return result; + } + + const attributeName = getSvgAttributeName(key); + const attributeValue = key === "style" && typeof value === "object" + ? serializeStyle(value as Record) + : String(value); + + if (!attributeValue) { + return result; + } + + result.push(value === true ? attributeName : `${attributeName}="${escapeAttribute(attributeValue)}"`); + return result; + }, [] as string[]); + + if (tagName === "svg" && !("xmlns" in (props || {}))) { + attributes.unshift(`xmlns="${SVG_NAMESPACE}"`); + } + + return attributes.length > 0 ? ` ${attributes.join(" ")}` : ""; +} + +function getSvgAttributeName(propName: string): string { + if (SVG_ATTRIBUTE_NAMES[propName]) { + return SVG_ATTRIBUTE_NAMES[propName]; + } + + if ( + CASE_SENSITIVE_SVG_ATTRIBUTES.has(propName) + || propName.startsWith("data-") + || propName.startsWith("aria-") + ) { + return propName; + } + + return propName.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`); +} + +function serializeStyle(style: Record): string { + return Object.keys(style) + .reduce((result, key) => { + const value = style[key]; + + if (value === null || value === undefined || value === false) { + return result; + } + + result.push(`${getSvgAttributeName(key)}:${String(value)}`); + return result; + }, [] as string[]) + .join(";"); +} + +function escapeText(value: string | number): string { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function escapeAttribute(value: string): string { + return escapeText(value).replace(/"/g, """); +} diff --git a/src/components/Svg/index.ts b/src/components/Svg/index.ts new file mode 100644 index 00000000..eb08068e --- /dev/null +++ b/src/components/Svg/index.ts @@ -0,0 +1,196 @@ +import { Fiber } from "react-reconciler"; +import { AppContainer } from "../../reconciler"; +import { ComponentConfig, RNComponent, RNProps, registerComponent } from "../config"; +import { + RNSvg, + RNSvgElement, + createSvgTextNode, + SvgCircleProps, + SvgElementProps, + SvgEllipseProps, + SvgLineProps, + SvgPathProps, + SvgPolygonProps, + SvgPolylineProps, + SvgProps, + SvgRectProps, + SvgTextProps, + createSvgElement, +} from "./RNSvg"; + +class SvgConfig extends ComponentConfig { + tagName = RNSvg.tagName; + + getContext(parentContext: any) { + return { + ...parentContext, + isInSvgTree: true, + }; + } + + shouldSetTextContent(nextProps: SvgProps): boolean { + return hasTextChildren(nextProps); + } + + createInstance( + newProps: SvgProps, + rootInstance: AppContainer, + context: any, + workInProgress: Fiber + ): RNSvg { + const widget = new RNSvg(); + widget.setProps(newProps, {}); + return widget; + } + + commitMount( + instance: RNSvg, + newProps: SvgProps, + internalInstanceHandle: any + ): void { + if (newProps.visible !== false) { + instance.show(); + } + } + + commitUpdate( + instance: RNSvg, + updatePayload: any, + oldProps: SvgProps, + newProps: SvgProps, + finishedWork: Fiber + ): void { + instance.setProps(newProps, oldProps); + } +} + +class SvgElementConfig extends ComponentConfig { + constructor( + readonly tagName: string, + private readonly svgTagName = tagName + ) { + super(); + } + + getContext(parentContext: any) { + return { + ...parentContext, + isInSvgTree: true, + }; + } + + shouldSetTextContent(nextProps: SvgElementProps): boolean { + return hasTextChildren(nextProps); + } + + createInstance = ( + newProps: SvgElementProps, + rootInstance: AppContainer, + context: any, + workInProgress: Fiber + ): RNSvgElement => { + return createSvgElement(this.svgTagName, newProps); + }; + + commitUpdate( + instance: RNComponent, + updatePayload: any, + oldProps: RNProps, + newProps: RNProps, + finishedWork: Fiber + ): void { + instance.setProps(newProps, oldProps); + } +} + +function hasTextChildren(props: RNProps): boolean { + const children = (props as { children?: unknown }).children; + return ( + typeof children === "string" + || typeof children === "number" + || ( + Array.isArray(children) + && children.every( + (child) => typeof child === "string" || typeof child === "number" + ) + ) + ); +} + +function registerSvgElement( + tagName: string, + svgTagName = tagName +) { + registeredSvgTags.add(tagName); + return registerComponent(new SvgElementConfig(tagName, svgTagName)); +} + +const registeredSvgTags = new Set(); + +export const Svg = registerComponent(new SvgConfig()); +export const G = registerSvgElement("g"); +export const Group = G; +export const Rect = registerSvgElement("rect"); +export const Circle = registerSvgElement("circle"); +export const Ellipse = registerSvgElement("ellipse"); +export const Line = registerSvgElement("line"); +export const Polygon = registerSvgElement("polygon"); +export const Polyline = registerSvgElement("polyline"); +export const Path = registerSvgElement("path"); +export const SvgText = registerSvgElement("svgText", "text"); + +const svgElementTags = [ + "a", + "animate", + "animateMotion", + "animateTransform", + "clipPath", + "defs", + "desc", + "feBlend", + "feColorMatrix", + "feComponentTransfer", + "feComposite", + "feConvolveMatrix", + "feDiffuseLighting", + "feDisplacementMap", + "feDistantLight", + "feDropShadow", + "feFlood", + "feFuncA", + "feFuncB", + "feFuncG", + "feFuncR", + "feGaussianBlur", + "feImage", + "feMerge", + "feMergeNode", + "feMorphology", + "feOffset", + "fePointLight", + "feSpecularLighting", + "feSpotLight", + "feTile", + "feTurbulence", + "filter", + "foreignObject", + "linearGradient", + "marker", + "mask", + "metadata", + "pattern", + "radialGradient", + "stop", + "style", + "switch", + "symbol", + "title", + "tspan", + "use", +]; + +for (const tagName of svgElementTags) { + if (!registeredSvgTags.has(tagName)) { + registerSvgElement(tagName); + } +} diff --git a/src/development/svg-acceptance.tsx b/src/development/svg-acceptance.tsx new file mode 100644 index 00000000..6251fb7d --- /dev/null +++ b/src/development/svg-acceptance.tsx @@ -0,0 +1,371 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import React from "react"; +import { Renderer } from "../renderer"; +import { appContainer } from "../reconciler"; +import { + Circle, + Path, + Rect, + Svg, + SvgText, + Text, + Window, +} from "../index"; +import { RNSvg, createSvgElement } from "../components/Svg/RNSvg"; + +type Resolve = () => void; +type Reject = (reason?: unknown) => void; + +const failures: string[] = []; +const warnings: string[] = []; +const originalWarn = console.warn; + +console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + originalWarn(...args); +}; + +function closeAllWindows() { + for (const widget of Array.from(appContainer)) { + if ((widget as any).close) { + (widget as any).close(); + } + appContainer.delete(widget); + } +} + +function assert(condition: unknown, message: string) { + if (!condition) { + throw new Error(message); + } +} + +function finishTest( + name: string, + error: unknown, + resolve: Resolve, + reject: Reject +) { + closeAllWindows(); + if (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${name}: ${message}`); + reject(error); + return; + } + console.log(`${name}: PASS`); + resolve(); +} + +function runRenderTest( + name: string, + element: React.ReactElement, + verify?: () => void, + timeoutMs = 5000 +) { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + finishTest(name, new Error("Timed out"), resolve, reject); + } + }, timeoutMs); + + Renderer.render(element, { + onRender: () => { + if (settled) { + return; + } + setTimeout(() => { + if (settled) { + return; + } + try { + verify && verify(); + settled = true; + clearTimeout(timer); + finishTest(name, null, resolve, reject); + } catch (error) { + settled = true; + clearTimeout(timer); + finishTest(name, error, resolve, reject); + } + }, 0); + }, + }); + }); +} + +async function verifyRootImport() { + await runRenderTest( + "root-import", + + ok + + ); +} + +async function verifyReadmeShapes() { + const svgRef = React.createRef(); + await runRenderTest( + "readme-helper-shapes", + + + + + + + , + () => { + const svg = svgRef.current!.toSvgString(); + assert(svg.includes("(); + await runRenderTest( + "lowercase-rect", + + + {React.createElement("rect", { + x: 0, + y: 0, + width: 100, + height: 100, + fill: "#00aa00", + })} + + , + () => { + const svg = svgRef.current!.toSvgString(); + assert(svg.includes('fill="#00aa00"'), "Lowercase rect was not serialized"); + } + ); +} + +async function verifySvgText() { + const svgRef = React.createRef(); + await runRenderTest( + "svg-text-helper", + + + + ok + + + , + () => { + const svg = svgRef.current!.toSvgString(); + assert(svg.includes(">ok"), "SvgText content missing"); + } + ); +} + +async function verifySvgContent() { + const content = + ""; + await runRenderTest( + "content-prop", + + + + ); +} + +async function verifySvgBuffer() { + const buffer = Buffer.from( + "" + ); + await runRenderTest( + "buffer-prop", + + + + ); +} + +async function verifySvgSrc() { + const svgPath = path.join(os.tmpdir(), "react-nodegui-svg-source.svg"); + fs.writeFileSync( + svgPath, + "" + ); + try { + await runRenderTest( + "src-prop", + + + + ); + } finally { + fs.rmSync(svgPath, { force: true }); + } +} + +async function verifyAdjacentText() { + const svgRef = React.createRef(); + await runRenderTest( + "adjacent-text", + + + + {"hello "} + {"world"} + + + , + () => { + const svg = svgRef.current!.toSvgString(); + assert(svg.includes(">hello world"), "Adjacent text did not serialize"); + } + ); +} + +async function verifyMixedTspan() { + const svgRef = React.createRef(); + await runRenderTest( + "mixed-tspan", + + + + {"hello "} + {React.createElement("tspan", { fill: "#f00" }, "there")} + {" friend"} + + + , + () => { + const svg = svgRef.current!.toSvgString(); + assert(svg.includes("hello "), "Mixed tspan leading text missing"); + assert( + svg.includes('there'), + "Mixed tspan child missing" + ); + assert(svg.includes(" friend"), "Mixed tspan trailing text missing"); + } + ); +} + +async function verifyMixedTextUpdate() { + return new Promise((resolve, reject) => { + const svgRef = React.createRef(); + let initialRender = true; + let timeout = setTimeout(() => { + finishTest("mixed-text-update", new Error("Timed out"), resolve, reject); + }, 5000); + + class App extends React.Component<{}, { leading: string }> { + state = { leading: "hello " }; + + componentDidMount() { + setTimeout(() => this.setState({ leading: "goodbye " }), 0); + } + + componentDidUpdate() { + try { + const svg = svgRef.current!.toSvgString(); + assert(svg.includes("goodbye "), "Updated leading text missing"); + assert(!svg.includes("hello "), "Stale leading text still present"); + assert( + svg.includes('there'), + "Updated mixed tspan child missing" + ); + clearTimeout(timeout); + finishTest("mixed-text-update", null, resolve, reject); + } catch (error) { + clearTimeout(timeout); + finishTest("mixed-text-update", error, resolve, reject); + } + } + + render() { + return ( + + + + {this.state.leading} + {React.createElement("tspan", { fill: "#f00" }, "there")} + {" friend"} + + + + ); + } + } + + Renderer.render(, { + onRender: () => { + if (initialRender) { + initialRender = false; + return; + } + }, + }); + }); +} + +function verifyReorderMove() { + const root = new RNSvg(); + root.setProps({ width: 100, height: 40, viewBox: "0 0 100 40" }, {}); + const a = createSvgElement("rect", { id: "a" }); + const b = createSvgElement("rect", { id: "b" }); + root.appendChild(a); + root.appendChild(b); + root.insertBefore(b, a); + + const svg = root.toSvgString(); + const countA = svg.split('id="a"').length - 1; + const countB = svg.split('id="b"').length - 1; + + assert(countA === 1, "Reorder duplicated child a"); + assert(countB === 1, "Reorder duplicated child b"); + assert( + svg.indexOf('id="b"') < svg.indexOf('id="a"'), + "Reorder did not move child before sibling" + ); + console.log("reorder-move: PASS"); +} + +async function main() { + try { + await verifyRootImport(); + await verifyReadmeShapes(); + await verifyLowercaseRect(); + await verifySvgText(); + await verifySvgContent(); + await verifySvgBuffer(); + await verifySvgSrc(); + await verifyAdjacentText(); + await verifyMixedTspan(); + await verifyMixedTextUpdate(); + verifyReorderMove(); + + assert(warnings.length === 0, `Unexpected warnings: ${warnings.join(" | ")}`); + console.log("svg-acceptance: PASS"); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error("svg-acceptance: FAIL"); + console.error(message); + if (failures.length > 0) { + console.error(failures.join("\n")); + } + process.exit(1); + } finally { + closeAllWindows(); + } +} + +void main(); diff --git a/src/index.ts b/src/index.ts index 78e18b80..6f06f199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,19 @@ 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, + Polyline, + Path, + SvgText, +} from "./components/Svg"; export { Button } from "./components/Button"; export { CheckBox } from "./components/CheckBox"; export { LineEdit } from "./components/LineEdit"; diff --git a/src/reconciler/index.ts b/src/reconciler/index.ts index ec5013af..26815f3c 100644 --- a/src/reconciler/index.ts +++ b/src/reconciler/index.ts @@ -6,6 +6,7 @@ import { RNProps, RNComponent } from "../components/config"; +import { createSvgTextNode, RNSvgTextNode } from "../components/Svg/RNSvg"; export type AppContainer = Set>; export const appContainer: AppContainer = new Set>(); @@ -48,6 +49,9 @@ const HostConfig: Reconciler.HostConfig< context, workInProgress ) { + if (context && context.isInSvgTree) { + return createSvgTextNode(String(newText)); + } // throw new Error(`Can't create text without for text: ${newText}`); console.warn( "createTextInstance called in reconciler when platform doesnt have host level text. " @@ -171,6 +175,10 @@ const HostConfig: Reconciler.HostConfig< } }, commitTextUpdate: (textInstance, oldText, newText) => { + if (textInstance instanceof RNSvgTextNode) { + textInstance.setText(String(newText)); + return; + } //noop since we manage all text using Text component console.warn( "commitTextUpdate called when platform doesnt have host level text"