Skip to content

Commit 1eb11fd

Browse files
authored
feat: autocomplete SVG attributes (#5331)
Ref #5258 Now user will see all available attributes for any SVG element. <img width="283" height="507" alt="image" src="https://github.com/user-attachments/assets/750c3027-aada-4e55-b429-c5adb5449158" />
1 parent 60ab7c7 commit 1eb11fd

File tree

12 files changed

+8802
-969
lines changed

12 files changed

+8802
-969
lines changed

apps/builder/app/builder/features/settings-panel/shared.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
type ComponentProps,
1212
} from "react";
1313
import equal from "fast-deep-equal";
14-
import { ariaAttributes, attributesByTag } from "@webstudio-is/html-data";
14+
import {
15+
ariaAttributes,
16+
attributesByTag,
17+
elementsByTag,
18+
} from "@webstudio-is/html-data";
1519
import {
1620
reactPropsToStandardAttributes,
1721
showAttribute,
@@ -465,13 +469,16 @@ export const $selectedInstancePropsMetas = computed(
465469
const propsMetas = new Map<Prop["name"], PropMeta>();
466470
// add html attributes only when instance has tag
467471
if (tag) {
468-
for (const attribute of [...ariaAttributes].reverse()) {
469-
propsMetas.set(attribute.name, attributeToMeta(attribute));
470-
}
471-
if (attributesByTag["*"]) {
472-
for (const attribute of [...attributesByTag["*"]].reverse()) {
472+
if (elementsByTag[tag].categories.includes("html-element")) {
473+
for (const attribute of [...ariaAttributes].reverse()) {
473474
propsMetas.set(attribute.name, attributeToMeta(attribute));
474475
}
476+
// include global attributes only for html elements
477+
if (attributesByTag["*"]) {
478+
for (const attribute of [...attributesByTag["*"]].reverse()) {
479+
propsMetas.set(attribute.name, attributeToMeta(attribute));
480+
}
481+
}
475482
}
476483
if (attributesByTag[tag]) {
477484
for (const attribute of [...attributesByTag[tag]].reverse()) {

packages/html-data/bin/attributes.ts

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { mkdir, writeFile } from "node:fs/promises";
2+
import hash from "@emotion/hash";
23
import {
34
coreMetas,
45
createScope,
@@ -10,13 +11,16 @@ import {
1011
} from "@webstudio-is/sdk";
1112
import { generateWebstudioComponent } from "@webstudio-is/react-sdk";
1213
import {
14+
findByClasses,
1315
findByTags,
1416
getAttr,
1517
getTextContent,
1618
loadHtmlIndices,
19+
loadSvgSinglePage,
1720
parseHtml,
1821
} from "./crawler";
1922
import { possibleStandardNames } from "./possible-standard-names";
23+
import { ignoredTags } from "./overrides";
2024

2125
const validHtmlAttributes = new Set<string>();
2226

@@ -28,14 +32,7 @@ type Attribute = {
2832
options?: string[];
2933
};
3034

31-
const overrides: Record<
32-
string,
33-
false | Record<string, false | Partial<Attribute>>
34-
> = {
35-
template: false,
36-
link: false,
37-
script: false,
38-
style: false,
35+
const overrides: Record<string, Record<string, false | Partial<Attribute>>> = {
3936
"*": {
4037
// react has own opinions about it
4138
style: false,
@@ -215,16 +212,15 @@ for (const row of rows) {
215212
if (/custom elements/i.test(tag)) {
216213
continue;
217214
}
218-
const tagOverride = overrides[tag];
219-
if (tagOverride === false) {
215+
if (ignoredTags.includes(tag)) {
220216
continue;
221217
}
222218
if (!attributesByTag[tag]) {
223219
attributesByTag[tag] = [];
224220
}
225221
const attributes = attributesByTag[tag];
226222
if (!attributes.some((item) => item.name === attribute)) {
227-
const override = tagOverride?.[attribute];
223+
const override = overrides[tag]?.[attribute];
228224
if (override !== false) {
229225
attributes.push({
230226
name: attribute,
@@ -238,28 +234,150 @@ for (const row of rows) {
238234
}
239235
}
240236

237+
{
238+
const svg = await loadSvgSinglePage();
239+
const document = parseHtml(svg);
240+
const attributeOptions = new Map<string, string[]>();
241+
// find all property definition and extract there keywords
242+
for (const propdef of findByClasses(document, "propdef")) {
243+
let options: undefined | string[];
244+
for (const row of findByTags(propdef, "tr")) {
245+
const [nameNode, valueNode] = row.childNodes;
246+
const name = getTextContent(nameNode);
247+
const list = getTextContent(valueNode)
248+
.trim()
249+
.split(/\s+\|\s+/);
250+
if (
251+
name.toLowerCase().includes("value") &&
252+
list.every((item) => item.match(/^[a-zA-Z-]+$/))
253+
) {
254+
options = list;
255+
}
256+
}
257+
for (const propNameNode of findByClasses(propdef, "propdef-title")) {
258+
const propName = getTextContent(propNameNode).slice(1, -1);
259+
if (options) {
260+
attributeOptions.set(propName, options);
261+
}
262+
}
263+
}
264+
265+
for (const summary of findByClasses(document, "element-summary")) {
266+
const [tag] = findByClasses(summary, "element-summary-name").map((item) =>
267+
getTextContent(item).slice(1, -1)
268+
);
269+
// ignore existing
270+
if (attributesByTag[tag] || ignoredTags.includes(tag)) {
271+
continue;
272+
}
273+
const attributes = new Set<string>();
274+
const [dl] = findByTags(summary, "dl");
275+
for (let index = 0; index < dl.childNodes.length; index += 1) {
276+
const child = dl.childNodes[index];
277+
if (getTextContent(child).toLowerCase().includes("attributes")) {
278+
const dd = dl.childNodes[index + 1];
279+
for (const attrNameNode of findByClasses(dd, "attr-name")) {
280+
const attrName = getTextContent(attrNameNode).slice(1, -1);
281+
// skip events
282+
if (attrName.startsWith("on") || attrName === "style") {
283+
continue;
284+
}
285+
validHtmlAttributes.add(attrName);
286+
attributes.add(attrName);
287+
}
288+
}
289+
}
290+
attributesByTag[tag] = Array.from(attributes)
291+
.sort()
292+
.map((name) => {
293+
let options = attributeOptions.get(name);
294+
if (name === "externalResourcesRequired") {
295+
options = ["true", "false"];
296+
}
297+
if (name === "accumulate") {
298+
options = ["none", "sum"];
299+
}
300+
if (name === "additive") {
301+
options = ["replace", "sum"];
302+
}
303+
if (name === "preserveAlpha") {
304+
options = ["true", "false"];
305+
}
306+
if (options) {
307+
return { name, description: "", type: "select", options };
308+
}
309+
return { name, description: "", type: "string" };
310+
});
311+
}
312+
}
313+
241314
// sort tags and attributes
242315
const tags = Object.keys(attributesByTag).sort();
316+
const attributesByHash = new Map<string, Attribute>();
317+
const reusableAttributesByHash = new Map<string, Attribute>();
243318
for (const tag of tags) {
244319
const attributes = attributesByTag[tag];
245320
delete attributesByTag[tag];
246-
attributes.sort();
321+
attributes.sort((left, right) => left.name.localeCompare(right.name));
247322
if (attributes.length > 0) {
323+
for (const attribute of attributes) {
324+
const attributeHash = hash(JSON.stringify(attribute));
325+
if (attributesByHash.has(attributeHash)) {
326+
reusableAttributesByHash.set(attributeHash, attribute);
327+
} else {
328+
attributesByHash.set(attributeHash, attribute);
329+
}
330+
}
248331
attributesByTag[tag] = attributes;
249332
}
250333
}
251334

252-
const attributesContent = `type Attribute = {
335+
let attributesContent = `type Attribute = {
253336
name: string,
254337
description: string,
255338
required?: boolean,
256339
type: 'string' | 'boolean' | 'number' | 'select' | 'url',
257340
options?: string[]
258341
}
259342
260-
export const attributesByTag: Record<string, undefined | Attribute[]> = ${JSON.stringify(attributesByTag, null, 2)};
261343
`;
262344

345+
const attributeVariableByHash = new Map<string, string>();
346+
for (const [key, attribute] of reusableAttributesByHash) {
347+
const normalizedName = attribute.name
348+
.replaceAll("-", "_")
349+
.replaceAll(":", "_");
350+
const variableName = `attribute_${normalizedName}_${key}`;
351+
attributeVariableByHash.set(key, variableName);
352+
attributesContent += `const ${variableName}: Attribute = ${JSON.stringify(attribute, null, 2)};\n\n`;
353+
}
354+
355+
const serializableAttributesByTag: Record<
356+
string,
357+
Array<string | Attribute>
358+
> = {};
359+
for (const tag of tags) {
360+
const attributes = attributesByTag[tag];
361+
serializableAttributesByTag[tag] = attributes.map((attribute) => {
362+
const key = hash(JSON.stringify(attribute));
363+
const variableName = attributeVariableByHash.get(key);
364+
if (variableName) {
365+
return variableName;
366+
}
367+
return attribute;
368+
});
369+
}
370+
371+
attributesContent += `
372+
export const attributesByTag: Record<string, undefined | Attribute[]> = ${JSON.stringify(serializableAttributesByTag, null, 2)};
373+
`;
374+
for (const variableName of attributeVariableByHash.values()) {
375+
attributesContent = attributesContent.replaceAll(
376+
`"${variableName}"`,
377+
variableName
378+
);
379+
}
380+
263381
await mkdir("./src/__generated__", { recursive: true });
264382
await writeFile("./src/__generated__/attributes.ts", attributesContent);
265383

packages/html-data/bin/crawler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export const findByClasses = (
3838
if (
3939
"tagName" in node &&
4040
node.attrs.some(
41-
(item) => item.name === "class" && item.value === className
41+
(item) =>
42+
item.name === "class" && item.value.split(/\s+/).includes(className)
4243
)
4344
) {
4445
result.push(node);

packages/html-data/bin/elements.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
loadSvgSinglePage,
99
parseHtml,
1010
} from "./crawler";
11+
import { ignoredTags } from "./overrides";
1112

1213
// Crawl WHATWG HTML.
1314

@@ -59,6 +60,9 @@ const elementsByTag: Record<string, Element> = {};
5960
categories.unshift("html-element");
6061
let children = parseList(getTextContent(row.childNodes[4]));
6162
for (const tag of elements) {
63+
if (ignoredTags.includes(tag)) {
64+
continue;
65+
}
6266
// textarea does not have value attribute and text content is used as initial value
6367
// introduce fake value attribute to manage initial state similar to input
6468
if (tag === "textarea") {
@@ -86,9 +90,12 @@ const elementsByTag: Record<string, Element> = {};
8690
const document = parseHtml(svg);
8791
const summaries = findByClasses(document, "element-summary");
8892
for (const summary of summaries) {
89-
const [name] = findByClasses(summary, "element-summary-name").map((item) =>
93+
const [tag] = findByClasses(summary, "element-summary-name").map((item) =>
9094
getTextContent(item).slice(1, -1)
9195
);
96+
if (ignoredTags.includes(tag)) {
97+
continue;
98+
}
9299
const children: string[] = [];
93100
const [dl] = findByTags(summary, "dl");
94101
for (let index = 0; index < dl.childNodes.length; index += 1) {
@@ -100,13 +107,13 @@ const elementsByTag: Record<string, Element> = {};
100107
}
101108
}
102109
}
103-
if (elementsByTag[name]) {
104-
console.info(`${name} element from SVG specification is skipped`);
110+
if (elementsByTag[tag]) {
111+
console.info(`${tag} element from SVG specification is skipped`);
105112
continue;
106113
}
107-
const categories = name === "svg" ? ["flow", "phrasing"] : ["none"];
108-
categories.unshift("svg-element");
109-
elementsByTag[name] = {
114+
const categories = tag === "svg" ? ["flow", "phrasing"] : ["none"];
115+
categories.unshift(tag === "svg" ? "html-element" : "svg-element");
116+
elementsByTag[tag] = {
110117
description: "",
111118
categories,
112119
children,
@@ -127,10 +134,7 @@ await mkdir(dirname(contentModelFile), { recursive: true });
127134
await writeFile(contentModelFile, contentModel);
128135

129136
const tags: string[] = [];
130-
for (const [tag, element] of Object.entries(elementsByTag)) {
131-
if (element.categories.includes("metadata")) {
132-
continue;
133-
}
137+
for (const tag of Object.keys(elementsByTag)) {
134138
tags.push(tag);
135139
}
136140
const getTagScore = (tag: string) => {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const ignoredTags: string[] = [
2+
"base",
3+
"template",
4+
"meta",
5+
"noscript",
6+
"link",
7+
"script",
8+
"style",
9+
"title",
10+
"glyph",
11+
"glyphRef",
12+
"altGlyph",
13+
"altGlyphDef",
14+
"altGlyphItem",
15+
"animateColor",
16+
"color-profile",
17+
"missing-glyph",
18+
"vkern",
19+
"hkern",
20+
"cursor",
21+
"tref",
22+
"font",
23+
"font-face",
24+
"font-face-format",
25+
"font-face-name",
26+
"font-face-src",
27+
"font-face-uri",
28+
];

packages/html-data/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"build:aria": "tsx --conditions=webstudio ./bin/aria.ts && prettier --write ./src/__generated__"
1313
},
1414
"devDependencies": {
15+
"@emotion/hash": "^0.9.2",
1516
"@types/aria-query": "^5.0.4",
1617
"@webstudio-is/react-sdk": "workspace:*",
1718
"@webstudio-is/sdk": "workspace:*",

0 commit comments

Comments
 (0)