From a4a7158ce90a055b80b93726e59b07e954ec1a7f Mon Sep 17 00:00:00 2001 From: Kirk Lin Date: Wed, 6 Dec 2023 22:16:49 +0800 Subject: [PATCH] feat: init --- package.json | 3 + pnpm-lock.yaml | 15 +++ src/constants.ts | 206 ++++++++++++++++++++++++++++++++ src/index.ts | 300 +++++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 57 +++++++++ 5 files changed, 581 insertions(+) create mode 100644 src/constants.ts create mode 100644 src/types.ts diff --git a/package.json b/package.json index 2f14002..c69c547 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,8 @@ }, "lint-staged": { "*": "eslint --fix" + }, + "dependencies": { + "@kirklin/palette": "^0.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c75bd3..6e5f889 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@kirklin/palette': + specifier: ^0.0.0 + version: 0.0.0 devDependencies: '@antfu/ni': specifier: ^0.21.12 @@ -1068,6 +1072,13 @@ packages: - vitest dev: true + /@kirklin/palette@0.0.0: + resolution: {integrity: sha512-dF6LsZAZNQ3F6+vWqVEBjdDrSX2Vs7jqsgHJ3xq/2L+oSd73wJ9JuTveNlHA/O/mgBhFS2ymuYtBfIeswssOxw==} + engines: {node: '>=16'} + dependencies: + colord: 2.9.3 + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2147,6 +2158,10 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true + /colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + dev: false + /colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dev: true diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..ba30b03 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,206 @@ +export const DEFAULT_COLORS = { + red: [ + "#fff2f0", + "#ffe9e6", + "#ffc3bd", + "#ff9b94", + "#ff706b", + "#f53f3f", + "#cf2b31", + "#a81b24", + "#820e1a", + "#5c0914", + ], + orangered: [ + "#fff7f0", + "#ffead9", + "#ffd1b0", + "#ffb587", + "#ff975e", + "#f77234", + "#d15321", + "#ab3913", + "#852308", + "#5e1505", + ], + orange: [ + "#fff6e6", + "#ffdca3", + "#ffc87a", + "#ffb152", + "#ff9729", + "#ff7d00", + "#d96200", + "#b34a00", + "#8c3600", + "#662400", + ], + gold: [ + "#fffdeb", + "#fff6c2", + "#ffec99", + "#ffe070", + "#ffd147", + "#f7ba1e", + "#d1940f", + "#ab7003", + "#855200", + "#5e3700", + ], + yellow: [ + "#feffe6", + "#ffffbd", + "#fffb94", + "#fff56b", + "#ffec42", + "#fadc19", + "#d4b20b", + "#ad8b00", + "#876800", + "#614700", + ], + lime: [ + "#fcffed", + "#f4ffc4", + "#e9ff9c", + "#d3f56e", + "#b9e843", + "#9fdb1d", + "#7bb50e", + "#5a8f04", + "#3d6900", + "#244200", + ], + green: [ + "#dcf5de", + "#95e89d", + "#69db78", + "#42cf5a", + "#1fc240", + "#00b42a", + "#008f26", + "#00691f", + "#004216", + "#001c0a", + ], + cyan: [ + "#e6fffb", + "#bbfcf4", + "#8bf0e6", + "#5fe3da", + "#38d6d1", + "#14c9c9", + "#089ea3", + "#00757d", + "#004e57", + "#002a30", + ], + blue: [ + "#f0f9ff", + "#d9f0ff", + "#b0ddff", + "#87c7ff", + "#5eafff", + "#3491fa", + "#226fd4", + "#1351ad", + "#083787", + "#052461", + ], + kirklinBlue: [ + "#e6f1ff", + "#bad8ff", + "#91bdff", + "#69a0ff", + "#407fff", + "#165dff", + "#0940d9", + "#002ab3", + "#001c8c", + "#001166", + ], + purple: [ + "#f9f0ff", + "#efdbff", + "#d3adf7", + "#b37feb", + "#9254de", + "#722ed1", + "#531dab", + "#391085", + "#22075e", + "#120338", + ], + pinkpurple: [ + "#ffebfc", + "#ffc2f7", + "#ff99f5", + "#f26be9", + "#e640e0", + "#d91ad9", + "#ad0cb3", + "#83038c", + "#5c0066", + "#370040", + ], + magenta: [ + "#fff0f6", + "#ffd6e7", + "#ffadd2", + "#ff85c0", + "#ff5cb0", + "#f5319d", + "#cf1f85", + "#a8116e", + "#820757", + "#5c0440", + ], + gray: [ + "#f7f8fa", + "#f2f3f5", + "#e5e6eb", + "#c9cdd4", + "#a9aeb8", + "#86909c", + "#6b7785", + "#4e5969", + "#272e3b", + "#1d2129", + ], + grey: [ + "#ced6db", + "#c2c9cf", + "#b6bdc2", + "#aab0b5", + "#9ea3a8", + "#86909c", + "#5f6875", + "#3c434f", + "#1d2129", + "#020203", + ], + black: [ + "#404040", + "#333333", + "#262626", + "#1a1a1a", + "#0d0d0d", + "#000000", + "#000000", + "#000000", + "#000000", + "#000000", + ], + white: [ + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#d9d9d9", + "#b3b3b3", + "#8c8c8c", + "#666666", + ], +}; diff --git a/src/index.ts b/src/index.ts index e69de29..769310b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,300 @@ +import { DEFAULT_COLORS } from "./constants"; +import type { Control, Wireframe } from "./types"; + +/** + * Convert a decimal color value to its RGB representation. + * @param color - The decimal color value. + * @returns The RGB representation of the color. + */ +function decimalToRGB(color: number): string { + const red = (color >> 16) & 255; // Extracting red channel + const green = (color >> 8) & 255; // Extracting green channel + const blue = color & 255; // Extracting blue channel + return `rgb(${red},${green},${blue})`; // Returning RGB representation +} + +// 创建 SVG 元素的辅助函数 +function createSVGElement(tag: string, attributes: Record = {}, parent?: SVGElement | HTMLElement): SVGElement { + const element: SVGElement = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const attr in attributes) { + if (Object.prototype.hasOwnProperty.call(attributes, attr)) { + element.setAttribute(attr, attributes[attr]); + } + } + if (parent) { + parent.appendChild(element); + } + return element; +} + +const BORDER_WIDTH = 2.7; +const ARROW_WIDTH = 4; +const RECT_RADIUS = 10; + +class Renderer { + private svgRoot: SVGElement; + private readonly fontFamily: string; + private canvasRenderingContext2D: CanvasRenderingContext2D; + + constructor(svgRoot: SVGElement, fontFamily: string) { + this.svgRoot = svgRoot; + this.fontFamily = fontFamily; + this.canvasRenderingContext2D = document.createElement("canvas").getContext("2d")!; + } + + render(control: Control, container: any) { + const type = control.typeID; + if (type in this) { + // eslint-disable-next-line ts/ban-ts-comment + // @ts-expect-error + this[type](control, container); + } else { + console.error(`'${type}' control type not implemented`); + } + } + + parseColor(color: any, defaultColor: any): string { + return color === undefined ? `rgb(${defaultColor})` : decimalToRGB(color); + } + + parseFontProperties(control: Control) { + const { properties } = control; + const style = properties && properties.italic ? "italic" : "normal"; + const weight = properties && properties.bold ? "bold" : "normal"; + const size = properties && properties.size ? `${properties.size}px` : "13px"; + const family = this.fontFamily; + + return { style, weight, size, family }; + } + + measureText(text: string, font: string): TextMetrics { + this.canvasRenderingContext2D.font = font; + return this.canvasRenderingContext2D.measureText(text); + } + + drawRectangle(control: Control, container: HTMLElement | undefined): void { + const { x, y, w, measuredW, h, measuredH, properties } = control; + const rectX = Number.parseInt(x) + BORDER_WIDTH / 2; + const rectY = Number.parseInt(y) + BORDER_WIDTH / 2; + const rectWidth = Number.parseInt(w ?? measuredW) - BORDER_WIDTH; + const rectHeight = Number.parseInt(h ?? measuredH) - BORDER_WIDTH; + + createSVGElement("rect", { + "x": rectX, + "y": rectY, + "width": rectWidth, + "height": rectHeight, + "rx": RECT_RADIUS, + "fill": this.parseColor(properties?.color, "255,255,255"), + "fill-opacity": properties?.backgroundAlpha ?? 1, + "stroke": this.parseColor(properties?.borderColor, "0,0,0"), + "stroke-width": BORDER_WIDTH, + }, container); + } + + addText( + control: Control, + container: HTMLElement | undefined, + textColor: string, + align: string, + ): void { + const textContent = control.properties.text ?? ""; + const xPosition = Number.parseInt(control.x); + const yPosition = Number.parseInt(control.y); + const fontProperties = this.parseFontProperties(control); + const textMetrics = this.measureText( + textContent, + `${fontProperties.style} ${fontProperties.weight} ${fontProperties.size} ${fontProperties.family}`, + ); + + const textX = align === "center" ? xPosition + (Number.parseInt(String(control.w)) ?? Number.parseInt(String(control.measuredW))) / 2 - textMetrics.width / 2 : xPosition; + const textY = yPosition + Number.parseInt(String(control.measuredH)) / 2 + textMetrics.actualBoundingBoxAscent / 2; + + const textElement = createSVGElement("text", { + "x": textX, + "y": textY, + "fill": textColor, + "font-style": fontProperties.style, + "font-weight": fontProperties.weight, + "font-size": fontProperties.size, + }, container); + + if (!textContent.includes("{color:")) { + const tspanElement = createSVGElement("tspan", {}, textElement); + tspanElement.textContent = textContent; + return; + } + + textContent.split(/{color:|{color}/).forEach((part) => { + if (part.includes("}")) { + let [colorCode, remainingText] = part.split("}"); + if (!colorCode.startsWith("#")) { + const colorIndex = Number.parseInt(colorCode.slice(-1)); + // eslint-disable-next-line ts/ban-ts-comment + // @ts-expect-error + colorCode = Number.isNaN(colorIndex) ? DEFAULT_COLORS[colorCode][5] : DEFAULT_COLORS[colorCode][colorIndex]; + } + const tspanElement = createSVGElement("tspan", { fill: colorCode }, textElement); + tspanElement.textContent = remainingText; + } else { + const tspanElement = createSVGElement("tspan", {}, textElement); + tspanElement.textContent = part; + } + }); + } + + TextArea(control: Control, container: HTMLElement | undefined): void { + this.drawRectangle(control, container); + } + + Canvas(control: Control, container: HTMLElement | undefined): void { + this.drawRectangle(control, container); + } + + Label(control: Control, container: HTMLElement | undefined): void { + this.addText( + control, + container, + this.parseColor(control.properties?.color, "0,0,0"), + "left", + ); + } + + TextInput(control: Control, container: HTMLElement | undefined): void { + this.drawRectangle(control, container); + this.addText( + control, + container, + this.parseColor(control.properties?.textColor, "0,0,0"), + "center", + ); + } + + Arrow(control: Control, container: HTMLElement | undefined): void { + const { x, y, properties: { p0: startPoint, p1: controlPoint, p2: endPoint, stroke: lineStyle, color } } = control; + const strokeWidth = ARROW_WIDTH; + let dashArray = ""; + + if (lineStyle === "dotted") { + dashArray = "0.8 12"; + } else if (lineStyle === "dashed") { + dashArray = "28 46"; + } + + const l = { x: (endPoint.x - startPoint.x) * controlPoint.x, y: (endPoint.y - startPoint.y) * controlPoint.x }; + + createSVGElement("path", { + "d": `M${Number.parseInt(x) + startPoint.x} ${Number.parseInt(y) + startPoint.y}Q${Number.parseInt(x) + startPoint.x + l.x + l.y * controlPoint.y * 3.6} ${Number.parseInt(y) + startPoint.y + l.y + -l.x * controlPoint.y * 3.6} ${Number.parseInt(x) + endPoint.x} ${Number.parseInt(y) + endPoint.y}`, + "fill": "none", + "stroke": this.parseColor(color, "0,0,0"), + "stroke-width": strokeWidth, + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-dasharray": dashArray, + }, container); + } + + Icon(control: Control, container: HTMLElement | undefined): void { + const { x, y, properties: { color, icon: { ID } } } = control; + const circleRadius = 10; + + createSVGElement("circle", { + cx: Number.parseInt(x) + circleRadius, + cy: Number.parseInt(y) + circleRadius, + r: circleRadius, + fill: this.parseColor(color, "0,0,0"), + }, container); + + if (ID === "check-circle") { + createSVGElement("path", { + "d": `M${Number.parseInt(x) + 4.5} ${Number.parseInt(y) + circleRadius}L${Number.parseInt(x) + 8.5} ${Number.parseInt(y) + circleRadius + 4} ${Number.parseInt(x) + 15} ${Number.parseInt(y) + circleRadius - 2.5}`, + "fill": "none", + "stroke": "#fff", + "stroke-width": 3.5, + "stroke-linecap": "round", + "stroke-linejoin": "round", + }, container); + } + } + + HRule(control: Control, container: HTMLElement | undefined): void { + const { x, y, properties: { stroke: lineStyle, color } } = control; + const strokeWidth = BORDER_WIDTH; + let dashArray = ""; + + if (lineStyle === "dotted") { + dashArray = "0.8, 8"; + } else if (lineStyle === "dashed") { + dashArray = "18, 30"; + } + + createSVGElement("path", { + "d": `M${x} ${y}L${Number.parseInt(x) + Number.parseInt(control.w ?? control.measuredW)} ${y}`, + "fill": "none", + "stroke": this.parseColor(color, "0,0,0"), + "stroke-width": strokeWidth, + "stroke-linecap": "round", + "stroke-linejoin": "round", + "stroke-dasharray": dashArray, + }, container); + } + + __group__(control: Control, container: any): void { + const groupName = control?.properties?.controlName || ""; + const groupElement = createSVGElement("g", groupName + ? { + "class": "clickable-group", + "data-group-id": groupName, + } + : {}, container); + + control?.children?.controls.control.sort((a: any, b: any) => a.zOrder - b.zOrder).forEach((child: any) => { + child.x = Number.parseInt(child.x, 10) + Number.parseInt(control.x, 10); + child.y = Number.parseInt(child.y, 10) + Number.parseInt(control.y, 10); + this.render(child, groupElement); + }); + } +} + +export async function wireframeJSONToSVG(wireframe: Wireframe, options: { padding?: number; fontFamily?: string; fontURL?: string } = {}): Promise { + options = { + padding: 5, + fontFamily: "sans-serif", + fontURL: "", + ...options, + }; + + if (options.fontURL) { + const font = new FontFace(options.fontFamily!, `url(${options.fontURL})`); + await font.load(); + // eslint-disable-next-line ts/ban-ts-comment + // @ts-expect-error + if (document.fonts.add) { + // eslint-disable-next-line ts/ban-ts-comment + // @ts-expect-error + document.fonts.add(font); + } + } + + const mockup = wireframe.mockup; + const padding = options.padding!; + const paddingRight = Number.parseInt(String(mockup.measuredW)) - Number.parseInt(String(mockup.mockupW)) - padding; + const paddingBottom = Number.parseInt(String(mockup.measuredH)) - Number.parseInt(String(mockup.mockupH)) - padding; + const svgWidth = Number.parseInt(String(mockup.mockupW)) + padding * 2; + const svgHeight = Number.parseInt(String(mockup.mockupH)) + padding * 2; + + const svgElement = createSVGElement("svg", { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "viewBox": `${paddingRight} ${paddingBottom} ${svgWidth} ${svgHeight}`, + "style": `font-family: ${options.fontFamily}`, + }); + + const renderer = new Renderer(svgElement, options.fontFamily!); + + mockup.controls.control.sort((a: any, b: any) => a.zOrder - b.zOrder).forEach((control: Control) => { + renderer.render(control, svgElement); + }); + + return svgElement; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5198f83 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,57 @@ +export interface Wireframe { + mockup: { + measuredW: string; // 界面的测量宽度 + measuredH: string; // 界面的测量高度 + mockupW: string; // 界面的宽度 + mockupH: string; // 界面的高度 + controls: { + control: Control[]; // 控件数组 + }; + // 其他属性... + [key: string]: any; + }; + attributes: { + name: string; // 名称 + order: number; // 排序顺序 + parentID: string | null; // 父级ID(可能为 null) + notes: string; // 备注 + // 其他属性... + [key: string]: any; + }; + branchID: string; // 分支ID + resourceID: string; // 资源ID + version: string; // 版本号 + groupOffset: { + x: number | string; // 组偏移的X坐标 + y: number | string; // 组偏移的Y坐标 + }; + dependencies: any[]; // 依赖项(可能为空数组) + projectID: string; // 项目ID + // 其他属性... + [key: string]: any; +} +export interface Control { + ID: string; // 控件的唯一标识符 + typeID: string; // 控件类型的标识 + zOrder: string; // 控件的层级顺序 + measuredW: string; // 控件的测量宽度 + measuredH: string; // 控件的测量高度 + w: string; // 控件的宽度 + h: string; // 控件的高度 + x: string; // 控件的 X 坐标 + y: string; // 控件的 Y 坐标 + properties: { + controlName?: string; // 控件名称(可能存在,可能为空) + size?: string; // 控件的文字大小(可能存在,可能为空) + text?: string; // 控件的文字内容(可能存在,可能为空) + // 其他属性... + [key: string]: any; + }; + children?: { + controls: { + control: Control[]; // 子控件数组 + }; + // 其他属性... + [key: string]: any; + }; +}