Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/code/export/createExportDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ export async function createExportDefinition(exportData: Export) {
baseEntry.height = roundTo(Math.max(baseEntry.height, componentNode.height), precision);

const weight = componentGroupValue.settings.weights[componentKey] ?? 1;
const tags = componentGroupValue.settings.tags[componentKey] ?? [];

baseEntry.variants[componentKey] = {
elements: convertSvgsonToDefinition(await parse(componentContentWithSvg)).children ?? [],
weight: weight === 1 ? undefined : weight,
tags: tags.length > 0 ? tags : undefined,
};
}
}
Expand Down Expand Up @@ -111,13 +113,13 @@ export async function createExportDefinition(exportData: Export) {
// Create definition
const bodyContent = await createTemplateString(
exportData,
(await figma.getNodeByIdAsync(exportData.frame.id)) as FrameNode,
(await figma.getNodeByIdAsync(exportData.frame.id)) as FrameNode
);
const bodyContentWithSvg = `<svg>${bodyContent}</svg>`;

return JSON.stringify(
removeEmptyValuesFromObject({
$schema: 'https://cdn.hopjs.net/npm/@dicebear/schema@1.1.0/dist/definition.min.json',
$schema: 'https://cdn.hopjs.net/npm/@dicebear/schema@1.3.0/dist/definition.min.json',
$comment:
'This file was generated by the DiceBear Exporter for Figma. https://www.figma.com/community/plugin/1005765655729342787',
meta: {
Expand Down Expand Up @@ -148,6 +150,6 @@ export async function createExportDefinition(exportData: Export) {
colors: colors,
}),
undefined,
2,
2
);
}
2 changes: 2 additions & 0 deletions src/code/export/prepareExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ function ensureMasterGroupRegistered(
...settings,
defaults: {},
weights: {},
tags: {},
},
collection: {},
width: 0,
Expand All @@ -220,6 +221,7 @@ function ensureMasterGroupRegistered(

componentGroup.settings.defaults[componentName] = settings.defaults[componentName] ?? true;
componentGroup.settings.weights[componentName] = settings.weights[componentName] ?? 1;
componentGroup.settings.tags[componentName] = settings.tags[componentName] ?? [];

pending.push(component);
}
Expand Down
1 change: 1 addition & 0 deletions src/code/settings/getComponentGroupSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function getComponentGroupSettings(frame: FrameNode, componentGroup: stri
return {
defaults: {},
weights: {},
tags: {},
probability: null,
rotation: null,
scale: null,
Expand Down
2 changes: 2 additions & 0 deletions src/code/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type RangeValue = DefinitionRange | null;
export type ComponentGroupSettings = {
defaults: Record<string, boolean>;
weights: Record<string, number>;
tags: Record<string, string[]>;
probability: number | null;
rotation: RangeValue;
scale: RangeValue;
Expand Down Expand Up @@ -115,6 +116,7 @@ export type DefinitionComponentBase = {
{
elements: DefinitionElement[];
weight?: number;
tags?: string[];
}
>;
};
Expand Down
173 changes: 173 additions & 0 deletions src/ui/components/TagsGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { reactive } from 'vue';
import { isValidVariantTag, MAX_VARIANT_TAGS } from '@/utils/sanitizeSettings';

const props = defineProps<{
values: Record<string, string[]>;
options: readonly string[];
}>();

// Per-variant draft text for the tag currently being typed. A commit that hits
// a malformed token leaves it in the draft so the user can see and fix it.
const drafts = reactive<Record<string, string>>({});

function list(name: string): string[] {
return (props.values[name] ??= []);
}

function commit(name: string): void {
const tokens = (drafts[name] ?? '')
.split(',')
.map((token) => token.trim())
.filter((token) => token.length > 0);

if (tokens.length === 0) {
drafts[name] = '';

return;
}

const tags = list(name);
const invalid: string[] = [];

for (const token of tokens) {
if (!isValidVariantTag(token)) {
invalid.push(token);
} else if (tags.length < MAX_VARIANT_TAGS && !tags.includes(token)) {
tags.push(token);
}
// A duplicate or an over-the-cap token is dropped silently: the existing
// chip, or the already-full row, explains why.
}

// Malformed tokens stay in the input so the user can see and correct them.
drafts[name] = invalid.join(', ');
}

function remove(name: string, tag: string): void {
const tags = list(name);
const index = tags.indexOf(tag);

if (index !== -1) {
tags.splice(index, 1);
}
}

function onKeydown(name: string, event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
commit(name);

return;
}

// Backspace on an empty input removes the last chip, like a typical tag
// field. Ignore key-repeat so a held key removes one chip per press.
if (event.key === 'Backspace' && !event.repeat && (drafts[name] ?? '') === '') {
const tags = list(name);

if (tags.length > 0) {
tags.splice(tags.length - 1, 1);
}
}
}
</script>

<template>
<div class="tag-list">
<div v-for="name in options" :key="name" class="tag-row">
<span class="tag-label">{{ name }}</span>
<div class="tag-control">
<span v-for="tag in values[name] ?? []" :key="tag" class="tag-chip">
{{ tag }}
<button type="button" class="tag-remove" :aria-label="`Remove ${tag}`" @click="remove(name, tag)">×</button>
</span>
<input
v-model="drafts[name]"
type="text"
class="tag-input"
:placeholder="(values[name]?.length ?? 0) === 0 ? 'mood:happy' : ''"
@keydown="onKeydown(name, $event)"
@blur="commit(name)"
/>
</div>
</div>
</div>
</template>

<style scoped>
.tag-list {
display: flex;
flex-direction: column;
gap: 8px;
}

.tag-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 11px;
}

.tag-label {
color: var(--figma-color-text);
flex: 0 0 30%;
padding-top: 6px;
}

.tag-control {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
min-height: 28px;
padding: 3px 6px;
border: 1px solid var(--figma-color-border);
border-radius: 4px;
background-color: var(--figma-color-bg);
}

.tag-control:focus-within {
border-color: var(--figma-color-border-selected);
}

.tag-chip {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 2px 1px 6px;
border-radius: 4px;
background-color: var(--figma-color-bg-secondary);
color: var(--figma-color-text);
font-variant-numeric: tabular-nums;
}

.tag-remove {
border: none;
background: transparent;
color: var(--figma-color-text-secondary);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0 2px;
}

.tag-remove:hover {
color: var(--figma-color-text);
}

.tag-input {
flex: 1;
min-width: 80px;
height: 20px;
border: none;
background: transparent;
color: var(--figma-color-text);
font-size: 11px;
}

.tag-input:focus {
outline: none;
}
</style>
25 changes: 23 additions & 2 deletions src/ui/components/forms/ComponentGroupForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import FieldReset from '../FieldReset.vue';
import RangeField from '../RangeField.vue';
import ToggleGroup from '../ToggleGroup.vue';
import WeightGroup from '../WeightGroup.vue';
import TagsGroup from '../TagsGroup.vue';

const props = defineProps<{ componentGroup: string }>();

Expand Down Expand Up @@ -67,15 +68,17 @@ function resetWeights(): void {
}
}

const tagsKeys = computed(() => Object.keys(settings.value.tags));

const tab = computed({
get: () => {
if (store.componentTab === 'weights' && !isDefinition.value) {
if ((store.componentTab === 'weights' || store.componentTab === 'tags') && !isDefinition.value) {
return 'settings';
}

return store.componentTab;
},
set: (next: 'settings' | 'weights' | 'normalize') => {
set: (next: 'settings' | 'weights' | 'tags' | 'normalize') => {
store.componentTab = next;
},
});
Expand Down Expand Up @@ -174,6 +177,9 @@ function onRetry() {
>
Weights
</button>
<button v-if="isDefinition" type="button" class="tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'">
Tags
</button>
<button type="button" class="tab" :class="{ active: tab === 'normalize' }" @click="tab = 'normalize'">
Normalize
</button>
Expand Down Expand Up @@ -257,6 +263,21 @@ function onRetry() {
</div>
</template>

<template v-else-if="tab === 'tags'">
<p class="weights-summary">
Tags describe variants so they can be filtered at render time (for example
<strong>mood:happy</strong> or <strong>hairLength:long</strong>). Each tag is
<strong>category</strong> or <strong>category:value</strong> in camelCase. Press Enter or comma to add one.
</p>

<div class="field">
<div class="field-label">
<span class="field-label-text">Tags</span>
</div>
<TagsGroup :values="settings.tags" :options="tagsKeys" />
</div>
</template>

<template v-else>
<div v-if="normalizeError" class="normalize-error">
<p>{{ normalizeError }}</p>
Expand Down
2 changes: 1 addition & 1 deletion src/ui/stores/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ExportData, NormalizeData } from '../types';

export type SceneType = 'loading' | 'loaded' | 'error';

export type ComponentTab = 'settings' | 'weights' | 'normalize';
export type ComponentTab = 'settings' | 'weights' | 'tags' | 'normalize';

export type StageKind = 'general' | 'package' | 'license' | 'hook' | 'component' | 'color';

Expand Down
1 change: 1 addition & 0 deletions src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type RangeValue = DefinitionRange | null;
export type ComponentGroupSettings = {
defaults: Record<string, boolean>;
weights: Record<string, number>;
tags: Record<string, string[]>;
probability: number | null;
rotation: RangeValue;
scale: RangeValue;
Expand Down
52 changes: 52 additions & 0 deletions src/ui/utils/sanitizeSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import type { ComponentGroupSettings, DefinitionRange, FrameSettings, RangeValue } from '../types';

// A single variant tag: `category` or `category:value`, each segment camelCase.
export const VARIANT_TAG_PATTERN = /^[a-z][a-zA-Z0-9]*(:[a-z][a-zA-Z0-9]*)?$/;

// Schema bounds for a variant's tags (definition.json): at most 32 tags, each
// up to 129 characters.
export const MAX_VARIANT_TAGS = 32;
const MAX_VARIANT_TAG_LENGTH = 129;

// One tag is valid when it matches the grammar and stays within the length
// bound. Shared with the authoring UI so the chip input and the sanitizer use
// the same rule.
export function isValidVariantTag(tag: string): boolean {
return tag.length <= MAX_VARIANT_TAG_LENGTH && VARIANT_TAG_PATTERN.test(tag);
}

export function sanitizeFrameSettings(settings: FrameSettings): void {
settings.packageName = settings.packageName.replace(/[^a-z0-9@\-\/]/gi, '');
settings.packageVersion = settings.packageVersion.replace(/[^0-9\.]/gi, '');
Expand Down Expand Up @@ -87,4 +102,41 @@ export function sanitizeComponentSettings(settings: ComponentGroupSettings): voi
weights[key] = next;
}
}

// Keep authored variant tags schema-valid: a valid token, unique, capped per
// variant. No `!` prefix. The exclusion form exists only in the render-option
// filter, not the data.
const tags = settings.tags;

for (const key of Object.keys(tags)) {
const raw = tags[key];

// The common case is an already-valid (often empty) list. Skip it without
// allocating a scratch Set and array, matching the weights loop above.
if (Array.isArray(raw) && raw.length === 0) {
continue;
}

const seen = new Set<string>();
const next: string[] = [];

if (Array.isArray(raw)) {
for (const tag of raw) {
if (typeof tag === 'string' && isValidVariantTag(tag) && !seen.has(tag)) {
seen.add(tag);
next.push(tag);

if (next.length === MAX_VARIANT_TAGS) {
break;
}
}
}
}

// `next` is `raw` with invalid or duplicate entries dropped, so equal
// lengths mean nothing changed.
if (!Array.isArray(raw) || raw.length !== next.length) {
tags[key] = next;
}
}
}