refactor: added tooltips and i18n new keys

This commit is contained in:
Santos Alarcón 2026-05-21 13:42:42 +02:00
parent 1e1939aea0
commit 33e89b30d1
11 changed files with 1217 additions and 1128 deletions

View file

@ -1,105 +1,122 @@
import type { Ref } from 'vue'
import type { Editor } from "@open-pencil/core/editor";
import type { Effect, SceneNode } from "@open-pencil/core/scene-graph";
import type { Color } from "@open-pencil/core/types";
import type { Ref } from "vue";
import { useI18n } from "#vue/i18n/useI18n.js";
import type { Editor } from '@open-pencil/core/editor'
import type { Effect, SceneNode } from '@open-pencil/core/scene-graph'
import type { Color } from '@open-pencil/core/types'
type EffectType = Effect["type"];
type EffectType = Effect['type']
const { panels } = useI18n();
const EFFECT_LABELS: Record<string, string> = {
DROP_SHADOW: 'Drop shadow',
INNER_SHADOW: 'Inner shadow',
LAYER_BLUR: 'Layer blur',
BACKGROUND_BLUR: 'Background blur',
FOREGROUND_BLUR: 'Foreground blur'
}
DROP_SHADOW: panels.value.dropShadow || "Drop shadow",
INNER_SHADOW: panels.value.innerShadow || "Inner shadow",
LAYER_BLUR: panels.value.layerBlur || "Layer blur",
BACKGROUND_BLUR: panels.value.backgroundBlur || "Background blur",
FOREGROUND_BLUR: panels.value.foregroundBlur || "Foreground blur",
};
export const EFFECT_TYPES = Object.keys(EFFECT_LABELS) as EffectType[]
export const EFFECT_OPTIONS = EFFECT_TYPES.map((t) => ({ value: t, label: EFFECT_LABELS[t] }))
export const EFFECT_TYPES = Object.keys(EFFECT_LABELS) as EffectType[];
export const EFFECT_OPTIONS = EFFECT_TYPES.map((t) => ({
value: t,
label: EFFECT_LABELS[t],
}));
export function isShadow(type: string) {
return type === 'DROP_SHADOW' || type === 'INNER_SHADOW'
return type === "DROP_SHADOW" || type === "INNER_SHADOW";
}
export function createDefaultEffect(): Effect {
return {
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 4,
spread: 0,
visible: true
}
return {
type: "DROP_SHADOW",
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 4,
spread: 0,
visible: true,
};
}
export function createEffectEditActions(editor: Editor, effectsBeforeScrub: Ref<Effect[] | null>) {
function scrubEffect(node: SceneNode | null, index: number, changes: Partial<Effect>) {
if (!node) return
if (!effectsBeforeScrub.value) {
effectsBeforeScrub.value = node.effects.map((e) => ({
...e,
color: { ...e.color },
offset: { ...e.offset }
}))
}
const effects = [...node.effects]
effects[index] = { ...effects[index], ...changes }
editor.updateNode(node.id, { effects })
editor.requestRender()
}
export function createEffectEditActions(
editor: Editor,
effectsBeforeScrub: Ref<Effect[] | null>,
) {
function scrubEffect(
node: SceneNode | null,
index: number,
changes: Partial<Effect>,
) {
if (!node) return;
if (!effectsBeforeScrub.value) {
effectsBeforeScrub.value = node.effects.map((e) => ({
...e,
color: { ...e.color },
offset: { ...e.offset },
}));
}
const effects = [...node.effects];
effects[index] = { ...effects[index], ...changes };
editor.updateNode(node.id, { effects });
editor.requestRender();
}
function commitEffect(node: SceneNode | null, index: number, changes: Partial<Effect>) {
if (!node) return
const previous = effectsBeforeScrub.value
effectsBeforeScrub.value = null
const effects = [...node.effects]
effects[index] = { ...effects[index], ...changes }
editor.updateNode(node.id, { effects })
editor.requestRender()
if (previous) {
editor.commitNodeUpdate(node.id, { effects: previous }, 'Change effect')
}
}
function commitEffect(
node: SceneNode | null,
index: number,
changes: Partial<Effect>,
) {
if (!node) return;
const previous = effectsBeforeScrub.value;
effectsBeforeScrub.value = null;
const effects = [...node.effects];
effects[index] = { ...effects[index], ...changes };
editor.updateNode(node.id, { effects });
editor.requestRender();
if (previous) {
editor.commitNodeUpdate(node.id, { effects: previous }, "Change effect");
}
}
return { scrubEffect, commitEffect }
return { scrubEffect, commitEffect };
}
export function createEffectControlActions(expandedIndex: Ref<number | null>) {
function updateType(
patch: (index: number, changes: Partial<Effect>) => void,
node: SceneNode | null,
index: number,
type: EffectType
) {
if (!node) return
const changes: Partial<Effect> = { type }
if (!isShadow(type)) {
changes.offset = { x: 0, y: 0 }
changes.spread = 0
} else if (!isShadow(node.effects[index].type)) {
changes.offset = { x: 0, y: 4 }
changes.spread = 0
}
patch(index, changes)
}
function updateType(
patch: (index: number, changes: Partial<Effect>) => void,
node: SceneNode | null,
index: number,
type: EffectType,
) {
if (!node) return;
const changes: Partial<Effect> = { type };
if (!isShadow(type)) {
changes.offset = { x: 0, y: 0 };
changes.spread = 0;
} else if (!isShadow(node.effects[index].type)) {
changes.offset = { x: 0, y: 4 };
changes.spread = 0;
}
patch(index, changes);
}
function updateColor(
patch: (index: number, changes: Partial<Effect>) => void,
index: number,
color: Color
) {
patch(index, { color })
}
function updateColor(
patch: (index: number, changes: Partial<Effect>) => void,
index: number,
color: Color,
) {
patch(index, { color });
}
function handleRemove(removeFn: (index: number) => void, index: number) {
removeFn(index)
if (expandedIndex.value === index) expandedIndex.value = null
else if (expandedIndex.value !== null && expandedIndex.value > index) expandedIndex.value--
}
function handleRemove(removeFn: (index: number) => void, index: number) {
removeFn(index);
if (expandedIndex.value === index) expandedIndex.value = null;
else if (expandedIndex.value !== null && expandedIndex.value > index)
expandedIndex.value--;
}
function toggleExpand(index: number) {
expandedIndex.value = expandedIndex.value === index ? null : index
}
function toggleExpand(index: number) {
expandedIndex.value = expandedIndex.value === index ? null : index;
}
return { updateType, updateColor, handleRemove, toggleExpand }
return { updateType, updateColor, handleRemove, toggleExpand };
}

View file

@ -1,375 +1,395 @@
import { createI18n, params } from '@nanostores/i18n'
import type { ComponentsJSON } from '@nanostores/i18n'
import { createI18n, params } from "@nanostores/i18n";
import type { ComponentsJSON } from "@nanostores/i18n";
import { locale } from '#vue/i18n/locale'
import type { Locale } from '#vue/i18n/locale'
import { locale } from "#vue/i18n/locale";
import type { Locale } from "#vue/i18n/locale";
const localeLoaders: Record<Exclude<Locale, 'en'>, () => Promise<{ default: ComponentsJSON }>> = {
de: () => import('#vue/locales/de.json'),
es: () => import('#vue/locales/es.json'),
fr: () => import('#vue/locales/fr.json'),
it: () => import('#vue/locales/it.json'),
pl: () => import('#vue/locales/pl.json'),
ru: () => import('#vue/locales/ru.json'),
'zh-CN': () => import('#vue/locales/zh-CN.json')
}
const localeLoaders: Record<
Exclude<Locale, "en">,
() => Promise<{ default: ComponentsJSON }>
> = {
de: () => import("#vue/locales/de.json"),
es: () => import("#vue/locales/es.json"),
fr: () => import("#vue/locales/fr.json"),
it: () => import("#vue/locales/it.json"),
pl: () => import("#vue/locales/pl.json"),
ru: () => import("#vue/locales/ru.json"),
"zh-CN": () => import("#vue/locales/zh-CN.json"),
};
const i18n = createI18n<Locale, 'en'>(locale, {
baseLocale: 'en',
async get(code) {
if (code === 'en') return {}
const mod = await localeLoaders[code]()
return mod.default
}
})
const i18n = createI18n<Locale, "en">(locale, {
baseLocale: "en",
async get(code) {
if (code === "en") return {};
const mod = await localeLoaders[code]();
return mod.default;
},
});
export const menuMessages = i18n('menu', {
file: 'File',
edit: 'Edit',
view: 'View',
object: 'Object',
arrange: 'Arrange',
text: 'Text',
export const menuMessages = i18n("menu", {
file: "File",
edit: "Edit",
view: "View",
object: "Object",
arrange: "Arrange",
text: "Text",
new: 'New',
open: 'Open…',
save: 'Save',
saveAs: 'Save as…',
exportSelection: 'Export selection…',
autosave: 'Auto-save to local file',
new: "New",
open: "Open…",
save: "Save",
saveAs: "Save as…",
exportSelection: "Export selection…",
autosave: "Auto-save to local file",
copy: 'Copy',
paste: 'Paste',
copy: "Copy",
paste: "Paste",
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
profiler: 'Performance profiler',
language: 'Language',
checkUpdates: 'Check for updates…',
zoomIn: "Zoom in",
zoomOut: "Zoom out",
profiler: "Performance profiler",
language: "Language",
checkUpdates: "Check for updates…",
moveToPage: 'Move to page',
createInstance: 'Create instance',
hide: 'Hide',
show: 'Show',
lock: 'Lock',
unlock: 'Unlock',
cut: 'Cut',
front: 'Front',
back: 'Back',
toggleUI: 'Toggle UI',
moveToPage: "Move to page",
createInstance: "Create instance",
hide: "Hide",
show: "Show",
lock: "Lock",
unlock: "Unlock",
cut: "Cut",
front: "Front",
back: "Back",
toggleUI: "Toggle UI",
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strikethrough: 'Strikethrough',
bold: "Bold",
italic: "Italic",
underline: "Underline",
strikethrough: "Strikethrough",
pasteHere: 'Paste here',
pasteToReplace: 'Paste to replace',
copyPasteAs: 'Copy/Paste as',
copyAsText: 'Copy as text',
copyAsSVG: 'Copy as SVG',
copyAsPNG: 'Copy as PNG',
copyAsJSX: 'Copy as JSX',
copyNodeId: 'Copy node ID',
copyXPath: 'Copy XPath',
booleanOperations: 'Boolean operations'
})
pasteHere: "Paste here",
pasteToReplace: "Paste to replace",
copyPasteAs: "Copy/Paste as",
copyAsText: "Copy as text",
copyAsSVG: "Copy as SVG",
copyAsPNG: "Copy as PNG",
copyAsJSX: "Copy as JSX",
copyNodeId: "Copy node ID",
copyXPath: "Copy XPath",
booleanOperations: "Boolean operations",
});
export const commandMessages = i18n('commands', {
undo: 'Undo',
redo: 'Redo',
selectAll: 'Select all',
duplicate: 'Duplicate',
delete: 'Delete',
group: 'Group',
groupSelection: 'Group selection',
frameSelection: 'Frame selection',
ungroup: 'Ungroup',
createComponent: 'Create component',
createComponentSet: 'Create component set',
createInstance: 'Create instance',
detachInstance: 'Detach instance',
goToMainComponent: 'Go to main component',
addAutoLayout: 'Add auto layout',
bringToFront: 'Bring to front',
sendToBack: 'Send to back',
showHide: 'Show/Hide',
lockUnlock: 'Lock/Unlock',
unionSelection: 'Union selection',
subtractSelection: 'Subtract selection',
intersectSelection: 'Intersect selection',
excludeSelection: 'Exclude selection',
flattenSelection: 'Flatten',
outlineText: 'Outline text',
outlineStroke: 'Outline stroke',
booleanOperations: 'Boolean operations',
flipHorizontal: 'Flip horizontal',
flipVertical: 'Flip vertical',
moveToPage: 'Move to page',
zoomTo100: 'Zoom to 100%',
zoomToFit: 'Zoom to fit',
zoomToSelection: 'Zoom to selection'
})
export const commandMessages = i18n("commands", {
undo: "Undo",
redo: "Redo",
selectAll: "Select all",
duplicate: "Duplicate",
delete: "Delete",
group: "Group",
groupSelection: "Group selection",
frameSelection: "Frame selection",
ungroup: "Ungroup",
createComponent: "Create component",
createComponentSet: "Create component set",
createInstance: "Create instance",
detachInstance: "Detach instance",
goToMainComponent: "Go to main component",
addAutoLayout: "Add auto layout",
bringToFront: "Bring to front",
sendToBack: "Send to back",
showHide: "Show/Hide",
lockUnlock: "Lock/Unlock",
unionSelection: "Union selection",
subtractSelection: "Subtract selection",
intersectSelection: "Intersect selection",
excludeSelection: "Exclude selection",
flattenSelection: "Flatten",
outlineText: "Outline text",
outlineStroke: "Outline stroke",
booleanOperations: "Boolean operations",
flipHorizontal: "Flip horizontal",
flipVertical: "Flip vertical",
moveToPage: "Move to page",
zoomTo100: "Zoom to 100%",
zoomToFit: "Zoom to fit",
zoomToSelection: "Zoom to selection",
});
export const toolMessages = i18n('tools', {
move: 'Move',
frame: 'Frame',
section: 'Section',
rectangle: 'Rectangle',
ellipse: 'Ellipse',
line: 'Line',
polygon: 'Polygon',
star: 'Star',
pen: 'Pen',
text: 'Text',
hand: 'Hand'
})
export const toolMessages = i18n("tools", {
move: "Move",
frame: "Frame",
section: "Section",
rectangle: "Rectangle",
ellipse: "Ellipse",
line: "Line",
polygon: "Polygon",
star: "Star",
pen: "Pen",
text: "Text",
hand: "Hand",
});
export const panelMessages = i18n('panels', {
layers: 'Layers',
pages: 'Pages',
design: 'Design',
code: 'Code',
ai: 'AI',
assets: 'Assets',
searchLocalComponents: 'Search local components',
assetLibraryBadge: 'Library',
assetVariantSummary: params('{count} variants · {names}'),
duplicateVariantValues: 'Duplicate variant values',
openDocumentation: 'Open documentation',
noLocalComponents: 'No local components',
componentSet: 'Component set',
component: 'Component',
insertInstance: 'Insert instance',
description: 'Description',
documentation: 'Documentation',
openDocs: 'Open docs',
properties: 'Properties',
export const panelMessages = i18n("panels", {
layers: "Layers",
pages: "Pages",
design: "Design",
code: "Code",
ai: "AI",
assets: "Assets",
searchLocalComponents: "Search local components",
assetLibraryBadge: "Library",
assetVariantSummary: params("{count} variants · {names}"),
duplicateVariantValues: "Duplicate variant values",
openDocumentation: "Open documentation",
noLocalComponents: "No local components",
componentSet: "Component set",
component: "Component",
insertInstance: "Insert instance",
description: "Description",
documentation: "Documentation",
openDocs: "Open docs",
properties: "Properties",
page: 'Page',
position: 'Position',
layout: 'Layout',
autoLayout: 'Auto layout',
alignment: 'Alignment',
appearance: 'Appearance',
fill: 'Fill',
stroke: 'Stroke',
effects: 'Effects',
export: 'Export',
typography: 'Typography',
variables: 'Variables',
variants: 'Variants',
constraints: 'Constraints',
xAxis: "X Axis",
yAxis: "Y Axis",
rotation: "Rotation",
width: "Width",
height: "Height",
opacity: "Opacity",
radius: "Radius",
spread: "Spread",
addFill: 'Add fill',
addStroke: 'Add stroke',
addEffect: 'Add effect',
addExport: 'Add export',
removeFill: 'Remove fill',
removeStroke: 'Remove stroke',
removeEffect: 'Remove effect',
removeExport: 'Remove export',
effectSettings: 'Effect settings',
expandEffectSettings: 'Expand effect settings',
collapseEffectSettings: 'Collapse effect settings',
toggleExportPreview: 'Toggle export preview',
page: "Page",
position: "Position",
layout: "Layout",
autoLayout: "Auto layout",
alignment: "Alignment",
appearance: "Appearance",
fill: "Fill",
stroke: "Stroke",
effects: "Effects",
export: "Export",
typography: "Typography",
variables: "Variables",
variants: "Variants",
constraints: "Constraints",
dropShadow: 'Drop shadow',
innerShadow: 'Inner shadow',
layerBlur: 'Layer blur',
backgroundBlur: 'Background blur',
addFill: "Add fill",
addStroke: "Add stroke",
addEffect: "Add effect",
addExport: "Add export",
removeFill: "Remove fill",
removeStroke: "Remove stroke",
removeEffect: "Remove effect",
removeExport: "Remove export",
effectSettings: "Effect settings",
expandEffectSettings: "Expand effect settings",
collapseEffectSettings: "Collapse effect settings",
toggleExportPreview: "Toggle export preview",
noSelection: 'No selection',
noLocalVariables: 'No local variables',
openVariables: 'Open variables',
addPage: 'Add page',
toggleVisibility: 'Toggle visibility',
independentCornerRadii: 'Independent corner radii',
detachVariable: 'Detach variable',
applyVariable: 'Apply variable',
noVariablesFound: 'No variables found',
addAutoLayout: 'Add auto layout',
removeAutoLayout: 'Remove auto layout',
alignLeft: 'Align left',
alignCenterHorizontally: 'Align center horizontally',
alignRight: 'Align right',
alignTop: 'Align top',
alignCenterVertically: 'Align center vertically',
alignBottom: 'Align bottom',
flipHorizontal: 'Flip horizontal',
flipVertical: 'Flip vertical',
rotate90: 'Rotate 90°',
mixedFillsHelp: 'Click + to replace mixed fills',
mixedStrokesHelp: 'Click + to replace mixed strokes',
mixedEffectsHelp: 'Click + to replace mixed effects',
strokeSides: 'Stroke sides',
strokeDash: 'Dashed stroke',
strokeAlignInside: 'Inside',
strokeAlignCenter: 'Center',
strokeAlignOutside: 'Outside',
exportPreview: 'Preview',
exportRenderingPreview: 'Rendering preview…',
create: 'Create',
add: 'Add',
createVariable: 'Create variable',
createColorVariable: params('Create color variable from {value}'),
createNumberVariable: params('Create number variable from {value}'),
variableName: 'Variable name',
mixed: 'Mixed',
layersCount: params('{count} layers'),
goToMainComponent: 'Go to Main Component',
detachInstance: 'Detach Instance',
dropShadow: "Drop shadow",
innerShadow: "Inner shadow",
layerBlur: "Layer blur",
backgroundBlur: "Background blur",
foregroundBlur: "Foreground blur",
solid: 'Solid',
linearGradient: 'Linear',
radialGradient: 'Radial',
image: 'Image',
stops: 'Stops',
addStop: 'Add stop',
strokeType: "Stroke type",
strokeWeight: "Stroke weight",
alignCenter: 'Align center',
alignMiddle: 'Align middle',
clipContent: 'Clip content',
colorFormatRgb: 'RGB',
colorFormatHsl: 'HSL',
colorFormatHsb: 'HSB',
colorFormatOkhcl: 'OkHCL',
colorHintHsl: 'H hue · S saturation · L lightness',
colorHintHsb: 'H hue · S saturation · B brightness',
colorHintOkhcl: 'H hue · C chroma · L lightness · A alpha',
colorPreviewClipped: params('Clipped to {space} preview gamut'),
rulers: 'Rulers',
multiplayerCursors: 'Multiplayer cursors',
direction: 'Direction',
flow: 'Flow',
gapAuto: 'Auto gap',
horizontalGap: 'Horizontal gap',
verticalGap: 'Vertical gap',
auto: 'Auto',
columns: 'Columns',
rows: 'Rows',
sizingFixed: 'Fixed',
sizingHug: 'Hug',
sizingFill: 'Fill',
sizingHugShort: 'Hug',
sizingFillShort: 'Fill',
addMinWidth: 'Add min width',
removeMinWidth: 'Remove min width',
addMaxWidth: 'Add max width',
removeMaxWidth: 'Remove max width',
addMinHeight: 'Add min height',
removeMinHeight: 'Remove min height',
addMaxHeight: 'Add max height',
removeMaxHeight: 'Remove max height',
minWidthShort: 'Min W',
maxWidthShort: 'Max W',
minHeightShort: 'Min H',
maxHeightShort: 'Max H',
setToCurrentWidth: 'Set to current width',
setToCurrentHeight: 'Set to current height',
sizingFillFr: 'Fill (fr)',
sizingFixedPx: 'Fixed (px)'
})
noSelection: "No selection",
noLocalVariables: "No local variables",
openVariables: "Open variables",
addPage: "Add page",
toggleVisibility: "Toggle visibility",
independentCornerRadii: "Independent corner radii",
detachVariable: "Detach variable",
applyVariable: "Apply variable",
noVariablesFound: "No variables found",
addAutoLayout: "Add auto layout",
removeAutoLayout: "Remove auto layout",
alignLeft: "Align left",
alignCenterHorizontally: "Align center horizontally",
alignRight: "Align right",
alignTop: "Align top",
alignCenterVertically: "Align center vertically",
alignBottom: "Align bottom",
flipHorizontal: "Flip horizontal",
flipVertical: "Flip vertical",
rotate90: "Rotate 90°",
mixedFillsHelp: "Click + to replace mixed fills",
mixedStrokesHelp: "Click + to replace mixed strokes",
mixedEffectsHelp: "Click + to replace mixed effects",
strokeSides: "Stroke sides",
strokeDash: "Dashed stroke",
strokeAlignInside: "Inside",
strokeAlignCenter: "Center",
strokeAlignOutside: "Outside",
exportScale: "Export scale",
exportFormat: "Export format",
exportPreview: "Preview",
exportRenderingPreview: "Rendering preview…",
create: "Create",
add: "Add",
createVariable: "Create variable",
createColorVariable: params("Create color variable from {value}"),
createNumberVariable: params("Create number variable from {value}"),
variableName: "Variable name",
mixed: "Mixed",
layersCount: params("{count} layers"),
goToMainComponent: "Go to Main Component",
detachInstance: "Detach Instance",
export const variableTypeMessages = i18n('variableTypes', {
color: 'Color',
colorHint: 'Paint values',
number: 'Number',
numberHint: 'Sizes, spacing, opacity',
text: 'Text',
textHint: 'Copy and labels',
boolean: 'Boolean',
booleanHint: 'True or false'
})
solid: "Solid",
linearGradient: "Linear",
radialGradient: "Radial",
image: "Image",
stops: "Stops",
addStop: "Add stop",
export const pageMessages = i18n('pages', {
newPage: 'New page',
rename: 'Rename',
delete: 'Delete',
pageName: params('Page {number}')
})
alignCenter: "Align center",
alignMiddle: "Align middle",
clipContent: "Clip content",
colorFormatRgb: "RGB",
colorFormatHsl: "HSL",
colorFormatHsb: "HSB",
colorFormatOkhcl: "OkHCL",
colorHintHsl: "H hue · S saturation · L lightness",
colorHintHsb: "H hue · S saturation · B brightness",
colorHintOkhcl: "H hue · C chroma · L lightness · A alpha",
colorPreviewClipped: params("Clipped to {space} preview gamut"),
rulers: "Rulers",
multiplayerCursors: "Multiplayer cursors",
direction: "Direction",
flow: "Flow",
gapAuto: "Auto gap",
horizontalGap: "Horizontal gap",
verticalGap: "Vertical gap",
auto: "Auto",
columns: "Columns",
rows: "Rows",
sizingFixed: "Fixed",
sizingHug: "Hug",
sizingFill: "Fill",
sizingHugShort: "Hug",
sizingFillShort: "Fill",
addMinWidth: "Add min width",
removeMinWidth: "Remove min width",
addMaxWidth: "Add max width",
removeMaxWidth: "Remove max width",
addMinHeight: "Add min height",
removeMinHeight: "Remove min height",
addMaxHeight: "Add max height",
removeMaxHeight: "Remove max height",
minWidthShort: "Min W",
maxWidthShort: "Max W",
minHeightShort: "Min H",
maxHeightShort: "Max H",
setToCurrentWidth: "Set to current width",
setToCurrentHeight: "Set to current height",
sizingFillFr: "Fill (fr)",
sizingFixedPx: "Fixed (px)",
});
export const dialogMessages = i18n('dialogs', {
cancel: 'Cancel',
apply: 'Apply',
close: 'Close',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
copiedExclamation: 'Copied!',
copyMessage: 'Copy message',
createCollection: 'Create collection',
renameCollection: 'Rename collection',
deleteCollection: 'Delete collection',
localVariables: 'Local variables',
noVariableCollections: 'No variable collections',
modes: 'Modes',
addMode: 'Add mode',
renameMode: 'Rename mode',
duplicateMode: 'Duplicate mode',
deleteMode: 'Delete mode',
setDefaultMode: 'Set as default',
selectLayerForJSX: 'Select a layer to see its JSX code',
copyJSXReference: 'Copy JSX prop reference to clipboard',
newTab: 'New tab',
closeTab: params('Close {name}'),
showUI: params('Show UI ({shortcut})'),
fontSettings: 'Font settings',
you: 'You',
youSuffix: 'you',
followingPeerStop: params('Following {name} (click to stop)'),
clickToFollowPeer: params('Click to follow {name}'),
connectAIProvider: 'Connect an AI provider to start chatting.',
connect: 'Connect',
getAPIKey: params('Get an {provider} API key →'),
oneKeyManyModels: 'One key for 100+ models from all providers.',
describeChange: 'Describe a change…',
describeCreateOrChange: 'Describe what you want to create or change.',
stopGenerating: 'Stop generating',
sendMessage: 'Send message',
baseURLPlaceholder: 'Base URL (e.g. http://localhost:11434/v1)',
modelIDPlaceholder: 'Model ID (e.g. llama-3.3-70b)',
aiProvider: 'AI Provider',
providerSettings: 'Provider settings',
done: 'Done',
apiKey: 'API Key',
apiType: 'API Type',
baseURL: 'Base URL',
modelID: 'Model ID',
maxOutputTokens: 'Max output tokens',
clear: 'Clear',
keySavedReplace: 'Key saved — enter new to replace',
getAPIKeyGeneric: 'Get API key →',
pexelsAPIKey: 'Pexels API Key (stock photos)',
unsplashAccessKey: 'Unsplash Access Key',
stockPhotoToolOptional: 'Optional — for stock_photo tool',
pexelsAlternativeOptional: 'Optional — alternative to Pexels',
getPexelsAPIKey: 'Get free Pexels API key →',
getUnsplashAccessKey: 'Get free Unsplash access key →',
completions: 'Completions',
responses: 'Responses',
yourName: 'Your name',
enterYourName: 'Enter your name',
shareThisFile: 'Share this file',
joinRoom: 'Join room',
join: 'Join',
roomLink: 'Room link',
joinCollaboration: 'Join collaboration',
orJoinRoom: 'or join a room',
pasteRoomLinkOrId: 'Paste room link or ID',
connected: 'Connected',
search: 'Search…',
noResults: 'No results',
share: 'Share',
appUpToDate: 'OpenPencil is up to date',
updateAvailableTitle: 'Update OpenPencil',
updateAvailable: params('OpenPencil {version} is available.'),
updateInstallPrompt:
'Download and install it now? The app will restart after the update is installed.',
downloadingUpdate: params('Downloading OpenPencil {version}'),
updateInstalledTitle: 'Update installed',
updateInstalled: params('OpenPencil {version} was installed{size}. Restarting now.'),
updateUnavailable:
'Updates are not available yet. Publish a signed release with latest.json first.',
updateCheckFailed: params('Could not check for updates: {error}')
})
export const variableTypeMessages = i18n("variableTypes", {
color: "Color",
colorHint: "Paint values",
number: "Number",
numberHint: "Sizes, spacing, opacity",
text: "Text",
textHint: "Copy and labels",
boolean: "Boolean",
booleanHint: "True or false",
});
export const pageMessages = i18n("pages", {
newPage: "New page",
rename: "Rename",
delete: "Delete",
pageName: params("Page {number}"),
});
export const dialogMessages = i18n("dialogs", {
cancel: "Cancel",
apply: "Apply",
close: "Close",
ok: "OK",
copy: "Copy",
copied: "Copied",
copiedExclamation: "Copied!",
copyMessage: "Copy message",
createCollection: "Create collection",
renameCollection: "Rename collection",
deleteCollection: "Delete collection",
localVariables: "Local variables",
noVariableCollections: "No variable collections",
modes: "Modes",
addMode: "Add mode",
renameMode: "Rename mode",
duplicateMode: "Duplicate mode",
deleteMode: "Delete mode",
setDefaultMode: "Set as default",
selectLayerForJSX: "Select a layer to see its JSX code",
copyJSXReference: "Copy JSX prop reference to clipboard",
newTab: "New tab",
closeTab: params("Close {name}"),
showUI: params("Show UI ({shortcut})"),
fontSettings: "Font settings",
you: "You",
youSuffix: "you",
followingPeerStop: params("Following {name} (click to stop)"),
clickToFollowPeer: params("Click to follow {name}"),
connectAIProvider: "Connect an AI provider to start chatting.",
connect: "Connect",
getAPIKey: params("Get an {provider} API key →"),
oneKeyManyModels: "One key for 100+ models from all providers.",
describeChange: "Describe a change…",
describeCreateOrChange: "Describe what you want to create or change.",
stopGenerating: "Stop generating",
sendMessage: "Send message",
baseURLPlaceholder: "Base URL (e.g. http://localhost:11434/v1)",
modelIDPlaceholder: "Model ID (e.g. llama-3.3-70b)",
aiProvider: "AI Provider",
providerSettings: "Provider settings",
done: "Done",
apiKey: "API Key",
apiType: "API Type",
baseURL: "Base URL",
modelID: "Model ID",
maxOutputTokens: "Max output tokens",
clear: "Clear",
keySavedReplace: "Key saved — enter new to replace",
getAPIKeyGeneric: "Get API key →",
pexelsAPIKey: "Pexels API Key (stock photos)",
unsplashAccessKey: "Unsplash Access Key",
stockPhotoToolOptional: "Optional — for stock_photo tool",
pexelsAlternativeOptional: "Optional — alternative to Pexels",
getPexelsAPIKey: "Get free Pexels API key →",
getUnsplashAccessKey: "Get free Unsplash access key →",
completions: "Completions",
responses: "Responses",
yourName: "Your name",
enterYourName: "Enter your name",
shareThisFile: "Share this file",
joinRoom: "Join room",
join: "Join",
roomLink: "Room link",
joinCollaboration: "Join collaboration",
orJoinRoom: "or join a room",
pasteRoomLinkOrId: "Paste room link or ID",
connected: "Connected",
search: "Search…",
noResults: "No results",
share: "Share",
appUpToDate: "OpenPencil is up to date",
updateAvailableTitle: "Update OpenPencil",
updateAvailable: params("OpenPencil {version} is available."),
updateInstallPrompt:
"Download and install it now? The app will restart after the update is installed.",
downloadingUpdate: params("Downloading OpenPencil {version}"),
updateInstalledTitle: "Update installed",
updateInstalled: params(
"OpenPencil {version} was installed{size}. Restarting now.",
),
updateUnavailable:
"Updates are not available yet. Publish a signed release with latest.json first.",
updateCheckFailed: params("Could not check for updates: {error}"),
});

View file

@ -1,332 +1,345 @@
{
"menu": {
"file": "Archivo",
"edit": "Editar",
"view": "Ver",
"object": "Objeto",
"arrange": "Organizar",
"text": "Texto",
"new": "Nuevo",
"open": "Abrir…",
"save": "Guardar",
"saveAs": "Guardar como…",
"exportSelection": "Exportar selección…",
"autosave": "Guardado automático en archivo",
"copy": "Copiar",
"paste": "Pegar",
"zoomIn": "Ampliar",
"zoomOut": "Reducir",
"profiler": "Perfil de rendimiento",
"language": "Idioma",
"moveToPage": "Mover a página",
"createInstance": "Crear instancia",
"hide": "Ocultar",
"show": "Mostrar",
"lock": "Bloquear",
"unlock": "Desbloquear",
"cut": "Cortar",
"front": "Al frente",
"back": "Al fondo",
"toggleUI": "Alternar interfaz",
"bold": "Negrita",
"italic": "Cursiva",
"underline": "Subrayado",
"pasteHere": "Pegar aquí",
"pasteToReplace": "Paste to replace",
"copyPasteAs": "Copiar/Pegar como",
"copyAsText": "Copiar como texto",
"copyAsSVG": "Copiar como SVG",
"copyAsPNG": "Copiar como PNG",
"copyAsJSX": "Copiar como JSX",
"copyNodeId": "Copiar ID del nodo",
"copyXPath": "Copiar XPath",
"checkUpdates": "Buscar actualizaciones…",
"booleanOperations": "Boolean operations",
"strikethrough": "Tachado"
},
"commands": {
"undo": "Deshacer",
"redo": "Rehacer",
"selectAll": "Seleccionar todo",
"duplicate": "Duplicar",
"delete": "Eliminar",
"group": "Agrupar",
"ungroup": "Desagrupar",
"createComponent": "Crear componente",
"createComponentSet": "Crear conjunto de componentes",
"createInstance": "Crear instancia",
"detachInstance": "Separar instancia",
"goToMainComponent": "Ir al componente principal",
"addAutoLayout": "Añadir auto-layout",
"bringToFront": "Traer al frente",
"sendToBack": "Enviar al fondo",
"moveToPage": "Mover a página",
"zoomTo100": "Zoom 100 %",
"zoomToFit": "Ajustar a pantalla",
"zoomToSelection": "Zoom a selección",
"groupSelection": "Agrupar",
"frameSelection": "Frame selection",
"showHide": "Visibilidad",
"lockUnlock": "Bloqueo",
"unionSelection": "Union selection",
"subtractSelection": "Subtract selection",
"intersectSelection": "Intersect selection",
"excludeSelection": "Exclude selection",
"booleanOperations": "Boolean operations",
"flipHorizontal": "Voltear horizontalmente",
"flipVertical": "Voltear verticalmente",
"flattenSelection": "Flatten",
"outlineText": "Outline text",
"outlineStroke": "Outline stroke"
},
"tools": {
"move": "Mover",
"frame": "Marco",
"section": "Sección",
"rectangle": "Rectángulo",
"ellipse": "Elipse",
"line": "Línea",
"polygon": "Polígono",
"star": "Estrella",
"pen": "Pluma",
"text": "Texto",
"hand": "Mano"
},
"panels": {
"layers": "Capas",
"pages": "Páginas",
"design": "Diseño",
"code": "Código",
"ai": "IA",
"assets": "Recursos",
"page": "Página",
"position": "Posición",
"layout": "Diseño",
"autoLayout": "Auto-layout",
"alignment": "Alineación",
"appearance": "Apariencia",
"fill": "Relleno",
"stroke": "Trazo",
"effects": "Efectos",
"export": "Exportar",
"typography": "Tipografía",
"variables": "Variables",
"constraints": "Restricciones",
"addFill": "Añadir relleno",
"addStroke": "Añadir trazo",
"addEffect": "Añadir efecto",
"addExport": "Añadir exportación",
"dropShadow": "Sombra",
"innerShadow": "Sombra interior",
"layerBlur": "Desenfoque de capa",
"backgroundBlur": "Desenfoque de fondo",
"noSelection": "Sin selección",
"noLocalVariables": "Sin variables locales",
"openVariables": "Abrir variables",
"addPage": "Añadir página",
"toggleVisibility": "Alternar visibilidad",
"independentCornerRadii": "Radios de esquina independientes",
"detachVariable": "Desvincular variable",
"applyVariable": "Aplicar variable",
"noVariablesFound": "No se encontraron variables",
"addAutoLayout": "Añadir auto-layout",
"removeAutoLayout": "Quitar auto-layout",
"mixed": "Mixto",
"layersCount": "{count} capas",
"goToMainComponent": "Ir al componente principal",
"detachInstance": "Separar instancia",
"solid": "Sólido",
"linearGradient": "Lineal",
"radialGradient": "Radial",
"image": "Imagen",
"stops": "Paradas",
"addStop": "Añadir parada",
"alignLeft": "Alinear a la izquierda",
"alignCenter": "Centrar",
"alignRight": "Alinear a la derecha",
"alignTop": "Alinear arriba",
"alignMiddle": "Centrar verticalmente",
"alignBottom": "Alinear abajo",
"alignCenterHorizontally": "Centrar horizontalmente",
"alignCenterVertically": "Centrar verticalmente",
"flipHorizontal": "Voltear horizontalmente",
"flipVertical": "Voltear verticalmente",
"rotate90": "Girar 90°",
"mixedFillsHelp": "Haz clic en + para reemplazar rellenos mixtos",
"mixedStrokesHelp": "Haz clic en + para reemplazar trazos mixtos",
"mixedEffectsHelp": "Haz clic en + para reemplazar efectos mixtos",
"strokeSides": "Lados del trazo",
"clipContent": "Recortar contenido",
"colorFormatRgb": "RGB",
"colorFormatHsl": "HSL",
"colorFormatHsb": "HSB",
"colorFormatOkhcl": "OkHCL",
"colorHintHsl": "H tono · S saturación · L luminosidad",
"colorHintHsb": "H tono · S saturación · B brillo",
"colorHintOkhcl": "H tono · C croma · L luminosidad · A alfa",
"colorPreviewClipped": "Recortado a gama de vista previa {space}",
"rulers": "Reglas",
"multiplayerCursors": "Cursores de otros participantes",
"direction": "Dirección",
"flow": "Flujo",
"auto": "Auto",
"columns": "Columnas",
"rows": "Filas",
"sizingFixed": "Fijo",
"sizingHug": "Ajustar al contenido",
"sizingFill": "Rellenar",
"sizingHugShort": "Ajust.",
"sizingFillShort": "Llenar",
"sizingFillFr": "Rellenar (fr)",
"sizingFixedPx": "Fijo (px)",
"strokeAlignInside": "Interior",
"strokeAlignCenter": "Centro",
"strokeAlignOutside": "Exterior",
"exportPreview": "Vista previa",
"exportRenderingPreview": "Renderizando vista previa…",
"createVariable": "Crear variable",
"createColorVariable": "Crear variable de color desde {value}",
"variableName": "Nombre de variable",
"create": "Crear",
"createNumberVariable": "Crear variable numérica desde {value}",
"strokeDash": "Trazo discontinuo",
"add": "Añadir",
"variants": "Variantes",
"gapAuto": "Espaciado auto",
"horizontalGap": "Espaciado horizontal",
"verticalGap": "Espaciado vertical",
"addMinWidth": "Agregar ancho min",
"removeMinWidth": "Eliminar ancho min",
"addMaxWidth": "Agregar ancho max",
"removeMaxWidth": "Eliminar ancho max",
"addMinHeight": "Agregar alto min",
"removeMinHeight": "Eliminar alto min",
"addMaxHeight": "Agregar alto max",
"removeMaxHeight": "Eliminar alto max",
"minWidthShort": "Min A",
"maxWidthShort": "Max A",
"minHeightShort": "Min Al",
"maxHeightShort": "Max Al",
"setToCurrentWidth": "Establecer ancho actual",
"setToCurrentHeight": "Establecer alto actual",
"searchLocalComponents": "Buscar componentes locales",
"assetLibraryBadge": "Biblioteca",
"assetVariantSummary": "{count} variantes · {names}",
"duplicateVariantValues": "Valores de variante duplicados",
"openDocumentation": "Abrir documentación",
"noLocalComponents": "No hay componentes locales",
"componentSet": "Conjunto de componentes",
"component": "Componente",
"insertInstance": "Insertar instancia",
"description": "Descripción",
"documentation": "Documentación",
"openDocs": "Abrir docs",
"properties": "Propiedades",
"removeFill": "Eliminar relleno",
"removeStroke": "Eliminar trazo",
"removeEffect": "Eliminar efecto",
"removeExport": "Eliminar exportación",
"effectSettings": "Ajustes del efecto",
"expandEffectSettings": "Expandir ajustes del efecto",
"collapseEffectSettings": "Contraer ajustes del efecto",
"toggleExportPreview": "Alternar vista previa de exportación"
},
"pages": {
"newPage": "Nueva página",
"rename": "Renombrar",
"delete": "Eliminar",
"pageName": "Página {number}"
},
"dialogs": {
"cancel": "Cancelar",
"apply": "Aplicar",
"close": "Cerrar",
"ok": "Aceptar",
"copy": "Copiar",
"copied": "Copiado",
"createCollection": "Crear colección",
"localVariables": "Variables locales",
"noVariableCollections": "No hay colecciones de variables",
"selectLayerForJSX": "Selecciona una capa para ver su código JSX",
"connectAIProvider": "Conecta un proveedor de IA para empezar a chatear.",
"connect": "Conectar",
"getAPIKey": "Obtener una clave API de {provider} →",
"oneKeyManyModels": "Una clave para más de 100 modelos de todos los proveedores.",
"describeChange": "Describe un cambio…",
"describeCreateOrChange": "Describe lo que quieres crear o cambiar.",
"stopGenerating": "Detener generación",
"sendMessage": "Enviar mensaje",
"baseURLPlaceholder": "Base URL (p. ej. http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (p. ej. llama-3.3-70b)",
"yourName": "Tu nombre",
"enterYourName": "Introduce tu nombre",
"shareThisFile": "Compartir este archivo",
"joinRoom": "Unirse a la sala",
"join": "Unirse",
"roomLink": "Enlace de la sala",
"joinCollaboration": "Unirse a la colaboración",
"orJoinRoom": "o unirse a una sala",
"pasteRoomLinkOrId": "Pega el enlace o ID de la sala",
"connected": "Conectado",
"search": "Buscar…",
"noResults": "Sin resultados",
"share": "Compartir",
"appUpToDate": "OpenPencil está actualizado",
"updateAvailableTitle": "Actualizar OpenPencil",
"updateAvailable": "OpenPencil {version} está disponible.",
"updateInstallPrompt": "¿Descargar e instalar ahora? La app se reiniciará después de instalar la actualización.",
"downloadingUpdate": "Descargando OpenPencil {version}",
"updateInstalledTitle": "Actualización instalada",
"updateInstalled": "OpenPencil {version} se instaló{size}. Reiniciando ahora.",
"updateCheckFailed": "No se pudo buscar actualizaciones: {error}",
"updateUnavailable": "Las actualizaciones aún no están disponibles. Publica primero una versión firmada con latest.json.",
"renameCollection": "Renombrar coleccion",
"deleteCollection": "Eliminar coleccion",
"modes": "Modos",
"addMode": "Agregar modo",
"renameMode": "Renombrar modo",
"duplicateMode": "Duplicar modo",
"deleteMode": "Eliminar modo",
"setDefaultMode": "Establecer como predeterminado",
"aiProvider": "AI Provider",
"providerSettings": "Provider settings",
"done": "Done",
"apiKey": "API Key",
"apiType": "API Type",
"baseURL": "Base URL",
"modelID": "Model ID",
"maxOutputTokens": "Max output tokens",
"clear": "Clear",
"keySavedReplace": "Key saved — enter new to replace",
"getAPIKeyGeneric": "Get API key →",
"pexelsAPIKey": "Pexels API Key (stock photos)",
"unsplashAccessKey": "Unsplash Access Key",
"stockPhotoToolOptional": "Optional — for stock_photo tool",
"pexelsAlternativeOptional": "Optional — alternative to Pexels",
"getPexelsAPIKey": "Get free Pexels API key →",
"getUnsplashAccessKey": "Get free Unsplash access key →",
"completions": "Completions",
"responses": "Responses",
"copiedExclamation": "¡Copiado!",
"copyMessage": "Copiar mensaje",
"copyJSXReference": "Copiar referencia de props JSX al portapapeles",
"newTab": "Nueva pestaña",
"closeTab": "Cerrar {name}",
"showUI": "Mostrar interfaz ({shortcut})",
"fontSettings": "Ajustes de fuentes",
"you": "Tú",
"youSuffix": "tú",
"followingPeerStop": "Siguiendo a {name} (haz clic para detener)",
"clickToFollowPeer": "Haz clic para seguir a {name}"
},
"variableTypes": {
"color": "Color",
"colorHint": "Valores de pintura",
"number": "Número",
"numberHint": "Tamaños, espaciado, opacidad",
"text": "Texto",
"textHint": "Texto y etiquetas",
"boolean": "Booleano",
"booleanHint": "Verdadero o falso"
}
"menu": {
"file": "Archivo",
"edit": "Editar",
"view": "Ver",
"object": "Objeto",
"arrange": "Organizar",
"text": "Texto",
"new": "Nuevo",
"open": "Abrir…",
"save": "Guardar",
"saveAs": "Guardar como…",
"exportSelection": "Exportar selección…",
"autosave": "Guardado automático en archivo",
"copy": "Copiar",
"paste": "Pegar",
"zoomIn": "Ampliar",
"zoomOut": "Reducir",
"profiler": "Perfil de rendimiento",
"language": "Idioma",
"moveToPage": "Mover a página",
"createInstance": "Crear instancia",
"hide": "Ocultar",
"show": "Mostrar",
"lock": "Bloquear",
"unlock": "Desbloquear",
"cut": "Cortar",
"front": "Al frente",
"back": "Al fondo",
"toggleUI": "Alternar interfaz",
"bold": "Negrita",
"italic": "Cursiva",
"underline": "Subrayado",
"pasteHere": "Pegar aquí",
"pasteToReplace": "Pegar y reemplazar",
"copyPasteAs": "Copiar/Pegar como",
"copyAsText": "Copiar como texto",
"copyAsSVG": "Copiar como SVG",
"copyAsPNG": "Copiar como PNG",
"copyAsJSX": "Copiar como JSX",
"copyNodeId": "Copiar ID del nodo",
"copyXPath": "Copiar XPath",
"checkUpdates": "Buscar actualizaciones…",
"booleanOperations": "Operaciones booleanas",
"strikethrough": "Tachado"
},
"commands": {
"undo": "Deshacer",
"redo": "Rehacer",
"selectAll": "Seleccionar todo",
"duplicate": "Duplicar",
"delete": "Eliminar",
"group": "Agrupar",
"ungroup": "Desagrupar",
"createComponent": "Crear componente",
"createComponentSet": "Crear conjunto de componentes",
"createInstance": "Crear instancia",
"detachInstance": "Separar instancia",
"goToMainComponent": "Ir al componente principal",
"addAutoLayout": "Añadir auto-layout",
"bringToFront": "Traer al frente",
"sendToBack": "Enviar al fondo",
"moveToPage": "Mover a página",
"zoomTo100": "Zoom 100 %",
"zoomToFit": "Ajustar a pantalla",
"zoomToSelection": "Zoom a selección",
"groupSelection": "Agrupar",
"frameSelection": "Frame selection",
"showHide": "Visibilidad",
"lockUnlock": "Bloqueo",
"unionSelection": "Selección de unión",
"subtractSelection": "Selección de sustración",
"intersectSelection": "Selección de intersección",
"excludeSelection": "Selección de exclusión",
"booleanOperations": "Operaciones boolean",
"flipHorizontal": "Voltear horizontalmente",
"flipVertical": "Voltear verticalmente",
"flattenSelection": "Aplanar",
"outlineText": "Contornear texto",
"outlineStroke": "Cortornear trazo"
},
"tools": {
"move": "Mover",
"frame": "Marco",
"section": "Sección",
"rectangle": "Rectángulo",
"ellipse": "Elipse",
"line": "Línea",
"polygon": "Polígono",
"star": "Estrella",
"pen": "Pluma",
"text": "Texto",
"hand": "Mano"
},
"panels": {
"layers": "Capas",
"pages": "Páginas",
"design": "Diseño",
"code": "Código",
"ai": "IA",
"assets": "Recursos",
"page": "Página",
"position": "Posición",
"layout": "Diseño",
"autoLayout": "Auto-layout",
"alignment": "Alineación",
"appearance": "Apariencia",
"xAxis": "Eje X",
"yAxis": "Eje Y",
"rotation": "Rotación",
"width": "Anchura",
"height": "Altura",
"opacity": "Opacidad",
"radius": "Redondeado",
"spread": "Esparcimiento",
"fill": "Relleno",
"stroke": "Trazo",
"effects": "Efectos",
"export": "Exportar",
"typography": "Tipografía",
"variables": "Variables",
"constraints": "Restricciones",
"addFill": "Añadir relleno",
"addStroke": "Añadir trazo",
"addEffect": "Añadir efecto",
"addExport": "Añadir exportación",
"dropShadow": "Sombra exterior",
"innerShadow": "Sombra interior",
"layerBlur": "Desenfoque de capa",
"backgroundBlur": "Desenfoque de fondo",
"foregroundBlur": "Desenfoque",
"strokeType": "Tipo de trazado",
"strokeWeight": "Grosor del trazado",
"noSelection": "Sin selección",
"noLocalVariables": "Sin variables locales",
"openVariables": "Abrir variables",
"addPage": "Añadir página",
"toggleVisibility": "Alternar visibilidad",
"independentCornerRadii": "Redondeo de esquina independientes",
"detachVariable": "Desvincular variable",
"applyVariable": "Aplicar variable",
"noVariablesFound": "No se encontraron variables",
"addAutoLayout": "Añadir auto-layout",
"removeAutoLayout": "Quitar auto-layout",
"mixed": "Mixto",
"layersCount": "{count} capas",
"goToMainComponent": "Ir al componente principal",
"detachInstance": "Separar instancia",
"solid": "Sólido",
"linearGradient": "Lineal",
"radialGradient": "Radial",
"image": "Imagen",
"stops": "Paradas",
"addStop": "Añadir parada",
"alignLeft": "Alinear a la izquierda",
"alignCenter": "Centrar",
"alignRight": "Alinear a la derecha",
"alignTop": "Alinear arriba",
"alignMiddle": "Centrar verticalmente",
"alignBottom": "Alinear abajo",
"alignCenterHorizontally": "Centrar horizontalmente",
"alignCenterVertically": "Centrar verticalmente",
"flipHorizontal": "Voltear horizontalmente",
"flipVertical": "Voltear verticalmente",
"rotate90": "Girar 90°",
"mixedFillsHelp": "Haz clic en + para reemplazar rellenos mixtos",
"mixedStrokesHelp": "Haz clic en + para reemplazar trazos mixtos",
"mixedEffectsHelp": "Haz clic en + para reemplazar efectos mixtos",
"strokeSides": "Lados del trazo",
"clipContent": "Recortar contenido",
"colorFormatRgb": "RGB",
"colorFormatHsl": "HSL",
"colorFormatHsb": "HSB",
"colorFormatOkhcl": "OkHCL",
"colorHintHsl": "H tono · S saturación · L luminosidad",
"colorHintHsb": "H tono · S saturación · B brillo",
"colorHintOkhcl": "H tono · C croma · L luminosidad · A alfa",
"colorPreviewClipped": "Recortado a gama de vista previa {space}",
"rulers": "Reglas",
"multiplayerCursors": "Cursores de otros participantes",
"direction": "Dirección",
"flow": "Flujo",
"auto": "Auto",
"columns": "Columnas",
"rows": "Filas",
"sizingFixed": "Fijo",
"sizingHug": "Ajustar al contenido",
"sizingFill": "Rellenar",
"sizingHugShort": "Ajust.",
"sizingFillShort": "Llenar",
"sizingFillFr": "Rellenar (fr)",
"sizingFixedPx": "Fijo (px)",
"strokeAlignInside": "Interior",
"strokeAlignCenter": "Centro",
"strokeAlignOutside": "Exterior",
"exportScale": "Escala de exportación",
"exportFormat": "Formato de exportación",
"exportPreview": "Vista previa",
"exportRenderingPreview": "Renderizando vista previa…",
"createVariable": "Crear variable",
"createColorVariable": "Crear variable de color desde {value}",
"variableName": "Nombre de variable",
"create": "Crear",
"createNumberVariable": "Crear variable numérica desde {value}",
"strokeDash": "Trazo discontinuo",
"add": "Añadir",
"variants": "Variantes",
"gapAuto": "Espaciado auto",
"horizontalGap": "Espaciado horizontal",
"verticalGap": "Espaciado vertical",
"addMinWidth": "Agregar ancho min",
"removeMinWidth": "Eliminar ancho min",
"addMaxWidth": "Agregar ancho max",
"removeMaxWidth": "Eliminar ancho max",
"addMinHeight": "Agregar alto min",
"removeMinHeight": "Eliminar alto min",
"addMaxHeight": "Agregar alto max",
"removeMaxHeight": "Eliminar alto max",
"minWidthShort": "Min A",
"maxWidthShort": "Max A",
"minHeightShort": "Min Al",
"maxHeightShort": "Max Al",
"setToCurrentWidth": "Establecer ancho actual",
"setToCurrentHeight": "Establecer alto actual",
"searchLocalComponents": "Buscar componentes locales",
"assetLibraryBadge": "Biblioteca",
"assetVariantSummary": "{count} variantes · {names}",
"duplicateVariantValues": "Valores de variante duplicados",
"openDocumentation": "Abrir documentación",
"noLocalComponents": "No hay componentes locales",
"componentSet": "Conjunto de componentes",
"component": "Componente",
"insertInstance": "Insertar instancia",
"description": "Descripción",
"documentation": "Documentación",
"openDocs": "Abrir docs",
"properties": "Propiedades",
"removeFill": "Eliminar relleno",
"removeStroke": "Eliminar trazo",
"removeEffect": "Eliminar efecto",
"removeExport": "Eliminar exportación",
"effectSettings": "Ajustes del efecto",
"expandEffectSettings": "Expandir ajustes del efecto",
"collapseEffectSettings": "Contraer ajustes del efecto",
"toggleExportPreview": "Alternar vista previa de exportación"
},
"pages": {
"newPage": "Nueva página",
"rename": "Renombrar",
"delete": "Eliminar",
"pageName": "Página {number}"
},
"dialogs": {
"cancel": "Cancelar",
"apply": "Aplicar",
"close": "Cerrar",
"ok": "Aceptar",
"copy": "Copiar",
"copied": "Copiado",
"createCollection": "Crear colección",
"localVariables": "Variables locales",
"noVariableCollections": "No hay colecciones de variables",
"selectLayerForJSX": "Selecciona una capa para ver su código JSX",
"connectAIProvider": "Conecta un proveedor de IA para empezar a chatear.",
"connect": "Conectar",
"getAPIKey": "Obtener una clave API de {provider} →",
"oneKeyManyModels": "Una clave para más de 100 modelos de todos los proveedores.",
"describeChange": "Describe un cambio…",
"describeCreateOrChange": "Describe lo que quieres crear o cambiar.",
"stopGenerating": "Detener generación",
"sendMessage": "Enviar mensaje",
"baseURLPlaceholder": "Base URL (p. ej. http://localhost:11434/v1)",
"modelIDPlaceholder": "Model ID (p. ej. llama-3.3-70b)",
"yourName": "Tu nombre",
"enterYourName": "Introduce tu nombre",
"shareThisFile": "Compartir este archivo",
"joinRoom": "Unirse a la sala",
"join": "Unirse",
"roomLink": "Enlace de la sala",
"joinCollaboration": "Unirse a la colaboración",
"orJoinRoom": "o unirse a una sala",
"pasteRoomLinkOrId": "Pega el enlace o ID de la sala",
"connected": "Conectado",
"search": "Buscar…",
"noResults": "Sin resultados",
"share": "Compartir",
"appUpToDate": "OpenPencil está actualizado",
"updateAvailableTitle": "Actualizar OpenPencil",
"updateAvailable": "Está disponible la versión {version} de OpenPencil.",
"updateInstallPrompt": "¿Descargar e instalar ahora? La app se reiniciará después de instalar la actualización.",
"downloadingUpdate": "Descargando OpenPencil {version}",
"updateInstalledTitle": "Actualización instalada",
"updateInstalled": "Se ha instalado la versión {version} de OpenPencil. Reiniciando ahora...",
"updateCheckFailed": "Error al encontrar actualizaciones: {error}",
"updateUnavailable": "Las actualizaciones aún no están disponibles. Publica primero una versión firmada con latest.json.",
"renameCollection": "Renombrar coleccion",
"deleteCollection": "Eliminar coleccion",
"modes": "Modos",
"addMode": "Agregar modo",
"renameMode": "Renombrar modo",
"duplicateMode": "Duplicar modo",
"deleteMode": "Eliminar modo",
"setDefaultMode": "Establecer como predeterminado",
"aiProvider": "Proveedor IA",
"providerSettings": "Opciones del proveedor",
"done": "Hecho",
"apiKey": "Clave API",
"apiType": "Tipo API",
"baseURL": "URL base",
"modelID": "ID Modelo",
"maxOutputTokens": "Tokens de salida máx.",
"clear": "Limpiar",
"keySavedReplace": "Clave guardada - introduce una nueva para reemplazarla",
"getAPIKeyGeneric": "Obtener clave API →",
"pexelsAPIKey": "Clave API de Pexels (fotos stock)",
"unsplashAccessKey": "Clave de acceso de Unsplash",
"stockPhotoToolOptional": "Opcional — para herramienta stock_photo",
"pexelsAlternativeOptional": "Opcional — alternativa a Pexels",
"getPexelsAPIKey": "Obtener clave a la API gratis de Pexels →",
"getUnsplashAccessKey": "Obtener clave a la API gratis de Unsplash →",
"completions": "Completados",
"responses": "Respuestas",
"copiedExclamation": "¡Copiado!",
"copyMessage": "Copiar mensaje",
"copyJSXReference": "Copiar referencia de props JSX al portapapeles",
"newTab": "Nueva pestaña",
"closeTab": "Cerrar {name}",
"showUI": "Mostrar interfaz ({shortcut})",
"fontSettings": "Ajustes de fuentes",
"you": "Tú",
"youSuffix": "tú",
"followingPeerStop": "Siguiendo a {name} (haz clic para detener)",
"clickToFollowPeer": "Haz clic para seguir a {name}"
},
"variableTypes": {
"color": "Color",
"colorHint": "Valores de pintura",
"number": "Número",
"numberHint": "Tamaños, espaciado, opacidad",
"text": "Texto",
"textHint": "Texto y etiquetas",
"boolean": "Booleano",
"booleanHint": "Verdadero o falso"
}
}

View file

@ -67,62 +67,66 @@ function onToggleCorners() {
</div>
<div class="flex gap-1.5">
<VariableScrubInput
v-if="node"
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
:node-id="node.id"
binding-path="opacity"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-else
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</ScrubInput>
<Tip :label="panels.opacity">
<VariableScrubInput
v-if="node"
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
:node-id="node.id"
binding-path="opacity"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-else
suffix="%"
:model-value="opacityPercent"
:min="0"
:max="100"
@update:model-value="updateProp('opacity', $event / 100)"
@commit="(v: number, p: number) => commitProp('opacity', v / 100, p / 100)"
>
<template #icon>
<icon-lucide-blend class="size-3" />
</template>
</ScrubInput>
</Tip>
<template v-if="hasCornerRadius">
<VariableScrubInput
v-if="!showIndependentCorners && node"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
:node-id="node.id"
binding-path="cornerRadius"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-else-if="!showIndependentCorners"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</ScrubInput>
<Tip :label="panels.radius">
<VariableScrubInput
v-if="!showIndependentCorners && node"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
:node-id="node.id"
binding-path="cornerRadius"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</VariableScrubInput>
<ScrubInput
v-else-if="!showIndependentCorners"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue"
:min="0"
@update:model-value="updateProp('cornerRadius', $event)"
@commit="(v: number, p: number) => commitProp('cornerRadius', v, p)"
>
<template #icon>
<icon-lucide-square-round-corner class="size-3" />
</template>
</ScrubInput>
</Tip>
<Tip :label="panels.independentCornerRadii">
<button

View file

@ -54,14 +54,16 @@ const { panels, dialogs } = useI18n()
<slot />
</div>
<ScrubInput
class="w-12 shrink-0"
suffix="%"
:model-value="opacityPercent(item.opacity)"
:min="0"
:max="100"
@update:model-value="emit('patch', { opacity: opacityFromPercent($event) })"
/>
<Tip :label="panels.opacity">
<ScrubInput
class="w-12 shrink-0"
suffix="%"
:model-value="opacityPercent(item.opacity)"
:min="0"
:max="100"
@update:model-value="emit('patch', { opacity: opacityFromPercent($event) })"
/>
</Tip>
<VariablePickerPopover
v-if="

View file

@ -103,46 +103,54 @@ const sectionCls = useSectionUI()
<div class="flex flex-col gap-1.5 py-1.5">
<template v-if="effectsCtx.isShadow(effect.type)">
<div class="flex items-center gap-1.5">
<ScrubInput
icon="X"
:model-value="effect.offset.x"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, { offset: { ...effect.offset, x: $event } })
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
offset: { ...effect.offset, x: $event }
})
"
/>
<ScrubInput
icon="Y"
:model-value="effect.offset.y"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, { offset: { ...effect.offset, y: $event } })
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
offset: { ...effect.offset, y: $event }
})
"
/>
<Tip :label="panels.xAxis">
<ScrubInput
icon="X"
:model-value="effect.offset.x"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, { offset: { ...effect.offset, x: $event } })
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
offset: { ...effect.offset, x: $event }
})
"
/>
</Tip>
<Tip :label="panels.yAxis">
<ScrubInput
icon="Y"
:model-value="effect.offset.y"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, { offset: { ...effect.offset, y: $event } })
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
offset: { ...effect.offset, y: $event }
})
"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
<ScrubInput
icon="B"
:model-value="effect.radius"
:min="0"
@update:model-value="effectsCtx.scrubEffect(activeNode, i, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, i, { radius: $event })"
/>
<ScrubInput
icon="S"
:model-value="effect.spread"
@update:model-value="effectsCtx.scrubEffect(activeNode, i, { spread: $event })"
@commit="effectsCtx.commitEffect(activeNode, i, { spread: $event })"
/>
<Tip :label="panels.radius">
<ScrubInput
icon="B"
:model-value="effect.radius"
:min="0"
@update:model-value="effectsCtx.scrubEffect(activeNode, i, { radius: $event })"
@commit="effectsCtx.commitEffect(activeNode, i, { radius: $event })"
/>
</Tip>
<Tip :label="panels.spread">
<ScrubInput
icon="S"
:model-value="effect.spread"
@update:model-value="effectsCtx.scrubEffect(activeNode, i, { spread: $event })"
@commit="effectsCtx.commitEffect(activeNode, i, { spread: $event })"
/>
</Tip>
</div>
<div class="flex items-center gap-1.5">
@ -151,23 +159,25 @@ const sectionCls = useSectionUI()
editable
@update="effectsCtx.updateColor(actions.patch, i, $event)"
/>
<ScrubInput
class="w-14"
suffix="%"
:model-value="Math.round(effect.color.a * 100)"
:min="0"
:max="100"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
/>
<Tip :label="panels.opacity">
<ScrubInput
class="w-14"
suffix="%"
:model-value="Math.round(effect.color.a * 100)"
:min="0"
:max="100"
@update:model-value="
effectsCtx.scrubEffect(activeNode, i, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
@commit="
effectsCtx.commitEffect(activeNode, i, {
color: { ...effect.color, a: Math.max(0, Math.min(1, $event / 100)) }
})
"
/>
</Tip>
</div>
</template>

View file

@ -150,17 +150,19 @@ onScopeDispose(() => {
:data-test-index="i"
class="flex items-center gap-1.5 py-0.5"
>
<AppSelect
v-if="formatSupportsScale(setting.format)"
:model-value="setting.scale"
:options="SCALE_OPTIONS"
@update:model-value="updateScale(i, Number($event))"
/>
<AppSelect
:model-value="setting.format"
:options="FORMAT_OPTIONS"
@update:model-value="updateFormat(i, $event as ExportFormatId)"
/>
<AppSelect
v-if="formatSupportsScale(setting.format)"
:model-value="setting.scale"
:options="SCALE_OPTIONS"
:label="panels.exportScale"
@update:model-value="updateScale(i, Number($event))"
/>
<AppSelect
:model-value="setting.format"
:options="FORMAT_OPTIONS"
:label="panels.exportFormat"
@update:model-value="updateFormat(i, $event as ExportFormatId)"
/>
<Tip :label="panels.removeExport">
<button
:class="useIconButtonUI({ ui: { base: 'shrink-0' } }).base"

View file

@ -170,167 +170,171 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
<template>
<div class="flex gap-1.5">
<div ref="widthFieldRef" class="min-w-0 flex-1">
<ScrubInput
data-test-id="layout-width-input"
icon="W"
:model-value="Math.round(resolvedBoundNumber('width') ?? ctx.node.width)"
:min="0"
@update:model-value="updateSizeProp('width', $event)"
@commit="(v: number, p: number) => commitSizeProp('width', v, p)"
>
<template #suffix>
<BoundVariableButton
v-if="widthVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-width-unbind-variable"
:label="panels.detachVariable"
@detach="widthVariableBinding.unbindVariable(ctx.node.id)"
/>
<VariablePickerPopover
v-else
v-model:search-term="widthVariableBinding.searchTerm.value"
:variables="widthVariableBinding.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-width-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.width) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-width-apply-variable-create'"
@select="bindSizeVariable('width', $event.id)"
@create="createAndBindSizeVariable('width', $event)"
/>
<SelectRoot
:model-value="ctx.widthSizing"
@update:model-value="handleSizeSelect('width', $event as SizeSelectValue)"
>
<SelectTrigger
data-test-id="layout-width-sizing-menu"
:reference="anchorRef(widthFieldRef)"
class="flex shrink-0 cursor-pointer items-center justify-center self-stretch border-none bg-transparent px-1.5 text-[11px] text-muted outline-none"
@pointerdown.stop
>
<icon-lucide-chevron-down class="size-3" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
align="start"
:side-offset="4"
:class="sizingSelect.content"
>
<SelectViewport class="p-0.5">
<SelectItem
v-for="opt in ctx.widthSizingOptions"
:key="opt.value"
:value="opt.value"
:class="sizingSelect.item"
>
<SelectItemIndicator
class="absolute left-1.5 inline-flex items-center justify-center"
>
<icon-lucide-check class="size-3 text-accent" />
</SelectItemIndicator>
<SelectItemText>{{ opt.label }}</SelectItemText>
</SelectItem>
<SelectItem
v-for="item in widthLimitItems"
:key="item.prop"
:value="`${ctx.node[item.prop] == null ? 'add' : 'remove'}-${item.prop}`"
:class="sizingSelect.item"
>
<SelectItemText>
{{ ctx.node[item.prop] == null ? item.addLabel() : item.removeLabel() }}
</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
</ScrubInput>
<Tip :label="panels.width">
<ScrubInput
data-test-id="layout-width-input"
icon="W"
:model-value="Math.round(resolvedBoundNumber('width') ?? ctx.node.width)"
:min="0"
@update:model-value="updateSizeProp('width', $event)"
@commit="(v: number, p: number) => commitSizeProp('width', v, p)"
>
<template #suffix>
<BoundVariableButton
v-if="widthVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-width-unbind-variable"
:label="panels.detachVariable"
@detach="widthVariableBinding.unbindVariable(ctx.node.id)"
/>
<VariablePickerPopover
v-else
v-model:search-term="widthVariableBinding.searchTerm.value"
:variables="widthVariableBinding.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-width-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.width) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-width-apply-variable-create'"
@select="bindSizeVariable('width', $event.id)"
@create="createAndBindSizeVariable('width', $event)"
/>
<SelectRoot
:model-value="ctx.widthSizing"
@update:model-value="handleSizeSelect('width', $event as SizeSelectValue)"
>
<SelectTrigger
data-test-id="layout-width-sizing-menu"
:reference="anchorRef(widthFieldRef)"
class="flex shrink-0 cursor-pointer items-center justify-center self-stretch border-none bg-transparent px-1.5 text-[11px] text-muted outline-none"
@pointerdown.stop
>
<icon-lucide-chevron-down class="size-3" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
align="start"
:side-offset="4"
:class="sizingSelect.content"
>
<SelectViewport class="p-0.5">
<SelectItem
v-for="opt in ctx.widthSizingOptions"
:key="opt.value"
:value="opt.value"
:class="sizingSelect.item"
>
<SelectItemIndicator
class="absolute left-1.5 inline-flex items-center justify-center"
>
<icon-lucide-check class="size-3 text-accent" />
</SelectItemIndicator>
<SelectItemText>{{ opt.label }}</SelectItemText>
</SelectItem>
<SelectItem
v-for="item in widthLimitItems"
:key="item.prop"
:value="`${ctx.node[item.prop] == null ? 'add' : 'remove'}-${item.prop}`"
:class="sizingSelect.item"
>
<SelectItemText>
{{ ctx.node[item.prop] == null ? item.addLabel() : item.removeLabel() }}
</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
</ScrubInput>
</Tip>
</div>
<div ref="heightFieldRef" class="min-w-0 flex-1">
<ScrubInput
data-test-id="layout-height-input"
icon="H"
:model-value="Math.round(resolvedBoundNumber('height') ?? ctx.node.height)"
:min="0"
@update:model-value="updateSizeProp('height', $event)"
@commit="(v: number, p: number) => commitSizeProp('height', v, p)"
>
<template #suffix>
<BoundVariableButton
v-if="heightVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-height-unbind-variable"
:label="panels.detachVariable"
@detach="heightVariableBinding.unbindVariable(ctx.node.id)"
/>
<VariablePickerPopover
v-else
v-model:search-term="heightVariableBinding.searchTerm.value"
:variables="heightVariableBinding.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-height-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.height) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-height-apply-variable-create'"
@select="bindSizeVariable('height', $event.id)"
@create="createAndBindSizeVariable('height', $event)"
/>
<SelectRoot
:model-value="ctx.heightSizing"
@update:model-value="handleSizeSelect('height', $event as SizeSelectValue)"
>
<SelectTrigger
data-test-id="layout-height-sizing-menu"
:reference="anchorRef(heightFieldRef)"
class="flex shrink-0 cursor-pointer items-center justify-center self-stretch border-none bg-transparent px-1.5 text-[11px] text-muted outline-none"
@pointerdown.stop
>
<icon-lucide-chevron-down class="size-3" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
align="start"
:side-offset="4"
:class="sizingSelect.content"
>
<SelectViewport class="p-0.5">
<SelectItem
v-for="opt in ctx.heightSizingOptions"
:key="opt.value"
:value="opt.value"
:class="sizingSelect.item"
>
<SelectItemIndicator
class="absolute left-1.5 inline-flex items-center justify-center"
>
<icon-lucide-check class="size-3 text-accent" />
</SelectItemIndicator>
<SelectItemText>{{ opt.label }}</SelectItemText>
</SelectItem>
<SelectItem
v-for="item in heightLimitItems"
:key="item.prop"
:value="`${ctx.node[item.prop] == null ? 'add' : 'remove'}-${item.prop}`"
:class="sizingSelect.item"
>
<SelectItemText>
{{ ctx.node[item.prop] == null ? item.addLabel() : item.removeLabel() }}
</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
</ScrubInput>
<Tip :label="panels.height">
<ScrubInput
data-test-id="layout-height-input"
icon="H"
:model-value="Math.round(resolvedBoundNumber('height') ?? ctx.node.height)"
:min="0"
@update:model-value="updateSizeProp('height', $event)"
@commit="(v: number, p: number) => commitSizeProp('height', v, p)"
>
<template #suffix>
<BoundVariableButton
v-if="heightVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-height-unbind-variable"
:label="panels.detachVariable"
@detach="heightVariableBinding.unbindVariable(ctx.node.id)"
/>
<VariablePickerPopover
v-else
v-model:search-term="heightVariableBinding.searchTerm.value"
:variables="heightVariableBinding.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-height-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.height) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-height-apply-variable-create'"
@select="bindSizeVariable('height', $event.id)"
@create="createAndBindSizeVariable('height', $event)"
/>
<SelectRoot
:model-value="ctx.heightSizing"
@update:model-value="handleSizeSelect('height', $event as SizeSelectValue)"
>
<SelectTrigger
data-test-id="layout-height-sizing-menu"
:reference="anchorRef(heightFieldRef)"
class="flex shrink-0 cursor-pointer items-center justify-center self-stretch border-none bg-transparent px-1.5 text-[11px] text-muted outline-none"
@pointerdown.stop
>
<icon-lucide-chevron-down class="size-3" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
align="start"
:side-offset="4"
:class="sizingSelect.content"
>
<SelectViewport class="p-0.5">
<SelectItem
v-for="opt in ctx.heightSizingOptions"
:key="opt.value"
:value="opt.value"
:class="sizingSelect.item"
>
<SelectItemIndicator
class="absolute left-1.5 inline-flex items-center justify-center"
>
<icon-lucide-check class="size-3 text-accent" />
</SelectItemIndicator>
<SelectItemText>{{ opt.label }}</SelectItemText>
</SelectItem>
<SelectItem
v-for="item in heightLimitItems"
:key="item.prop"
:value="`${ctx.node[item.prop] == null ? 'add' : 'remove'}-${item.prop}`"
:class="sizingSelect.item"
>
<SelectItemText>
{{ ctx.node[item.prop] == null ? item.addLabel() : item.removeLabel() }}
</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
</ScrubInput>
</Tip>
</div>
</div>

View file

@ -93,51 +93,61 @@ function handleAlign(
</div>
<div class="flex gap-1.5">
<ScrubInput
icon="X"
:model-value="xValue"
@update:model-value="actions.updateProp('x', $event)"
@commit="(v: number, p: number) => actions.commitProp('x', v, p)"
/>
<ScrubInput
icon="Y"
:model-value="yValue"
@update:model-value="actions.updateProp('y', $event)"
@commit="(v: number, p: number) => actions.commitProp('y', v, p)"
/>
<Tip :label="panels.xAxis">
<ScrubInput
icon="X"
:model-value="xValue"
@update:model-value="actions.updateProp('x', $event)"
@commit="(v: number, p: number) => actions.commitProp('x', v, p)"
/>
</Tip>
<Tip :label="panels.yAxis">
<ScrubInput
icon="Y"
:model-value="yValue"
@update:model-value="actions.updateProp('y', $event)"
@commit="(v: number, p: number) => actions.commitProp('y', v, p)"
/>
</Tip>
</div>
<div v-if="isMulti" class="mt-1.5 flex gap-1.5">
<ScrubInput
icon="W"
:model-value="wValue"
:min="1"
@update:model-value="actions.updateProp('width', $event)"
@commit="(v: number, p: number) => actions.commitProp('width', v, p)"
/>
<ScrubInput
icon="H"
:model-value="hValue"
:min="1"
@update:model-value="actions.updateProp('height', $event)"
@commit="(v: number, p: number) => actions.commitProp('height', v, p)"
/>
<Tip :label="panels.width">
<ScrubInput
icon="W"
:model-value="wValue"
:min="1"
@update:model-value="actions.updateProp('width', $event)"
@commit="(v: number, p: number) => actions.commitProp('width', v, p)"
/>
</Tip>
<Tip :label="panels.height">
<ScrubInput
icon="H"
:model-value="hValue"
:min="1"
@update:model-value="actions.updateProp('height', $event)"
@commit="(v: number, p: number) => actions.commitProp('height', v, p)"
/>
</Tip>
</div>
<div class="mt-1.5 flex items-center gap-1.5">
<ScrubInput
class="flex-1"
suffix="°"
:model-value="rotationValue"
:min="-360"
:max="360"
@update:model-value="actions.updateProp('rotation', $event)"
@commit="(v: number, p: number) => actions.commitProp('rotation', v, p)"
>
<Tip :label="panels.rotation">
<ScrubInput
class="flex-1"
suffix="°"
:model-value="rotationValue"
:min="-360"
:max="360"
@update:model-value="actions.updateProp('rotation', $event)"
@commit="(v: number, p: number) => actions.commitProp('rotation', v, p)"
>
<template #icon>
<icon-lucide-rotate-cw class="size-3" />
</template>
</ScrubInput>
</Tip>
<Tip :label="panels.flipHorizontal">
<button
:class="useIconButtonUI({ size: 'md', ui: { base: 'shrink-0' } }).base"

View file

@ -150,33 +150,36 @@ function setGap(stroke: Stroke | undefined, patch: StrokePatch, value: number) {
v-if="!isMixed && (items as unknown[]).length > 0"
class="mt-1 flex items-center gap-1.5"
>
<AppSelect
class="w-[72px]"
:model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode!)"
/>
<ScrubInput
v-if="!expandedSides"
class="flex-1"
:model-value="activeNode!.strokes[0]?.weight ?? 1"
:min="0"
@update:model-value="actions.patch(0, { weight: $event })"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<line x1="1" y1="3" x2="11" y2="3" />
<line x1="1" y1="6" x2="11" y2="6" />
<line x1="1" y1="9" x2="11" y2="9" />
</svg>
</template>
</ScrubInput>
<AppSelect
class="w-[72px]"
:label="panels.strokeType"
:model-value="strokeCtx.currentAlign(activeNode)"
:options="strokeCtx.alignOptions"
@update:model-value="strokeCtx.updateAlign($event as Stroke['align'], activeNode!)"
/>
<Tip :label="panels.strokeWeight">
<ScrubInput
v-if="!expandedSides"
class="flex-1"
:model-value="activeNode!.strokes[0]?.weight ?? 1"
:min="0"
@update:model-value="actions.patch(0, { weight: $event })"
>
<template #icon>
<svg
class="size-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<line x1="1" y1="3" x2="11" y2="3" />
<line x1="1" y1="6" x2="11" y2="6" />
<line x1="1" y1="9" x2="11" y2="9" />
</svg>
</template>
</ScrubInput>
</Tip>
<Tip :label="panels.strokeSides">
<button
data-test-id="stroke-sides-toggle"

View file

@ -22,6 +22,7 @@ interface AppSelectUi extends SelectUi {
}
interface AppSelectProps<TValue extends string | number> extends TestIdProps {
label?: string
options: { value: TValue; label: string }[]
placeholder?: string
ui?: AppSelectUi
@ -29,6 +30,7 @@ interface AppSelectProps<TValue extends string | number> extends TestIdProps {
const {
options,
label,
placeholder,
ui,
testId = 'app-select-trigger'
@ -47,24 +49,26 @@ const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center j
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger v-test-id="testId" :class="select.trigger">
<SelectValue :placeholder="placeholder" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
</SelectTrigger>
<Tip :label="label">
<SelectTrigger v-test-id="testId" :class="select.trigger">
<SelectValue :placeholder="placeholder" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
</SelectTrigger>
</Tip>
<SelectPortal>
<SelectContent position="popper" :side-offset="2" :class="select.content">
<SelectViewport :class="viewport">
<SelectItem
v-for="opt in options"
:key="String(opt.value)"
:value="opt.value"
:class="select.item"
>
<SelectItemIndicator :class="indicator">
<icon-lucide-check class="size-3 text-accent" />
</SelectItemIndicator>
<SelectItemText>{{ opt.label }}</SelectItemText>
</SelectItem>
<SelectItem
v-for="opt in options"
:key="String(opt.value)"
:value="opt.value"
:class="select.item"
>
<SelectItemIndicator :class="indicator">
<icon-lucide-check class="size-3 text-accent" />
</SelectItemIndicator>
<SelectItemText>{{ opt.label }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>