feat: init

This commit is contained in:
Kirk Lin 2023-12-06 22:16:49 +08:00
parent 30083154a8
commit a4a7158ce9
5 changed files with 581 additions and 0 deletions

View file

@ -71,5 +71,8 @@
},
"lint-staged": {
"*": "eslint --fix"
},
"dependencies": {
"@kirklin/palette": "^0.0.0"
}
}

15
pnpm-lock.yaml generated
View file

@ -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

206
src/constants.ts Normal file
View file

@ -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",
],
};

View file

@ -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<string, any> = {}, 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<SVGElement> {
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;
}

57
src/types.ts Normal file
View file

@ -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;
};
}