fix(ui): align layout sections with Figma

This commit is contained in:
Danila Poyarkov 2026-07-26 10:52:48 +03:00
parent a23e58d785
commit 80750e65fc
17 changed files with 78 additions and 76 deletions

View file

@ -25,6 +25,7 @@
### Changed
- Group sizing and auto-layout controls into one contextual Design panel section, and use Figma’s current Layout guide terminology.
- Redesign the editor chrome and Design panel with denser aligned controls, clearer selection and section states, improved menus and overlays, consistent light/dark theming, and better keyboard and screen-reader behavior.
- Scale the Layers panel to documents with thousands of nodes through virtualized rows, faster incremental updates, stable expansion, range selection, and scroll-to-selection.
- Resolve fonts before text appears, with language-aware CJK and Arabic fallback, character-specific remote subsets, and more reliable rendering as fonts load.

View file

@ -131,9 +131,9 @@
"layoutHorizontal": "Horizontales Layout",
"layoutVertical": "Vertikales Layout",
"layoutGrid": "Rasterlayout",
"layoutGrids": "Layout-Raster",
"addLayoutGrid": "Layout-Raster hinzufügen",
"removeLayoutGrid": "Layout-Raster entfernen",
"layoutGrids": "Layout-Hilfslinien",
"addLayoutGrid": "Layout-Hilfslinie hinzufügen",
"removeLayoutGrid": "Layout-Hilfslinie entfernen",
"gridGrid": "Raster",
"gridColumns": "Spalten",
"gridRows": "Zeilen",

View file

@ -145,9 +145,9 @@
"layoutHorizontal": "Diseño horizontal",
"layoutVertical": "Diseño vertical",
"layoutGrid": "Diseño de cuadrícula",
"layoutGrids": "Cuadrículas de diseño",
"addLayoutGrid": "Añadir cuadrícula de diseño",
"removeLayoutGrid": "Eliminar cuadrícula de diseño",
"layoutGrids": "Guías de diseño",
"addLayoutGrid": "Añadir guía de diseño",
"removeLayoutGrid": "Eliminar guía de diseño",
"gridGrid": "Cuadrícula",
"gridColumns": "Columnas",
"gridRows": "Filas",

View file

@ -131,9 +131,9 @@
"layoutHorizontal": "Disposition horizontale",
"layoutVertical": "Disposition verticale",
"layoutGrid": "Disposition en grille",
"layoutGrids": "Grilles de mise en page",
"addLayoutGrid": "Ajouter une grille de mise en page",
"removeLayoutGrid": "Supprimer la grille de mise en page",
"layoutGrids": "Repères de mise en page",
"addLayoutGrid": "Ajouter un repère de mise en page",
"removeLayoutGrid": "Supprimer le repère de mise en page",
"gridGrid": "Grille",
"gridColumns": "Colonnes",
"gridRows": "Rangées",

View file

@ -131,9 +131,9 @@
"layoutHorizontal": "Layout orizzontale",
"layoutVertical": "Layout verticale",
"layoutGrid": "Layout a griglia",
"layoutGrids": "Griglie di layout",
"addLayoutGrid": "Aggiungi griglia di layout",
"removeLayoutGrid": "Rimuovi griglia di layout",
"layoutGrids": "Guide di layout",
"addLayoutGrid": "Aggiungi guida di layout",
"removeLayoutGrid": "Rimuovi guida di layout",
"gridGrid": "Griglia",
"gridColumns": "Colonne",
"gridRows": "Righe",

View file

@ -145,9 +145,9 @@
"layoutHorizontal": "横方向レイアウト",
"layoutVertical": "縦方向レイアウト",
"layoutGrid": "グリッドレイアウト",
"layoutGrids": "レイアウトグリッド",
"addLayoutGrid": "レイアウトグリッドを追加",
"removeLayoutGrid": "レイアウトグリッドを削除",
"layoutGrids": "レイアウトガイド",
"addLayoutGrid": "レイアウトガイドを追加",
"removeLayoutGrid": "レイアウトガイドを削除",
"gridGrid": "グリッド",
"gridColumns": "列",
"gridRows": "行",

View file

@ -131,9 +131,9 @@
"layoutHorizontal": "Układ poziomy",
"layoutVertical": "Układ pionowy",
"layoutGrid": "Układ siatki",
"layoutGrids": "Siatki układu",
"addLayoutGrid": "Dodaj siatkę układu",
"removeLayoutGrid": "Usuń siatkę układu",
"layoutGrids": "Prowadnice układu",
"addLayoutGrid": "Dodaj prowadnicę układu",
"removeLayoutGrid": "Usuń prowadnicę układu",
"gridGrid": "Siatka",
"gridColumns": "Kolumny",
"gridRows": "Wiersze",

View file

@ -131,9 +131,9 @@
"layoutHorizontal": "Горизонтальная раскладка",
"layoutVertical": "Вертикальная раскладка",
"layoutGrid": "Сетка",
"layoutGrids": "Сетки макета",
"addLayoutGrid": "Добавить сетку макета",
"removeLayoutGrid": "Удалить сетку макета",
"layoutGrids": "Направляющие макета",
"addLayoutGrid": "Добавить направляющую макета",
"removeLayoutGrid": "Удалить направляющую макета",
"gridGrid": "Сетка",
"gridColumns": "Столбцы",
"gridRows": "Строки",

View file

@ -131,9 +131,9 @@
"layoutHorizontal": "水平布局",
"layoutVertical": "垂直布局",
"layoutGrid": "网格布局",
"layoutGrids": "布局网格",
"addLayoutGrid": "添加布局网格",
"removeLayoutGrid": "移除布局网格",
"layoutGrids": "布局参考线",
"addLayoutGrid": "添加布局参考线",
"removeLayoutGrid": "移除布局参考线",
"gridGrid": "网格",
"gridColumns": "列",
"gridRows": "行",

View file

@ -266,9 +266,9 @@ export const panelMessageDefaults = {
resizeAutoWidth: 'Auto width',
resizeAutoHeight: 'Auto height',
resizeFixed: 'Fixed size',
layoutGrids: 'Layout grid',
addLayoutGrid: 'Add layout grid',
removeLayoutGrid: 'Remove layout grid',
layoutGrids: 'Layout guide',
addLayoutGrid: 'Add layout guide',
removeLayoutGrid: 'Remove layout guide',
gridColumns: 'Columns',
gridRows: 'Rows',
gridGrid: 'Grid',

View file

@ -13,6 +13,7 @@ import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutGridSection from './properties/LayoutSection/LayoutGridSection.vue'
import LayoutSection from './properties/LayoutSection/LayoutSection.vue'
import MaskSection from './properties/MaskSection.vue'
import PageSection from './properties/PageSection.vue'
@ -39,6 +40,10 @@ const isComponentType = computed(() => {
return type ? COMPONENT_TYPES.has(type) : false
})
const selectedIcon = computed(() => (node.value ? nodeIcon(node.value) : undefined))
const supportsLayoutGuides = computed(() => {
const type = node.value?.type
return type === 'FRAME' || type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'INSTANCE'
})
const { panels } = useI18n()
</script>
@ -131,6 +136,7 @@ const { panels } = useI18n()
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<LayoutGridSection v-if="supportsLayoutGuides" />
<EffectsSection />
<ExportSection />

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useLayoutControlsContext, useSceneComputed } from '@open-pencil/vue'
import { useEditor, useI18n, useSceneComputed } from '@open-pencil/vue'
import NumberField from '@/components/inputs/NumberField.vue'
import IconButton from '@/components/ui/IconButton.vue'
@ -10,13 +10,14 @@ import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import PanelItemRow from '@/components/ui/panel/PanelItemRow.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import type { LayoutGrid } from '@open-pencil/scene-graph'
const ctx = useLayoutControlsContext()
const editor = useEditor()
const { panels } = useI18n()
const selectedNode = useSceneComputed(() => ctx.editor.getSelectedNode() ?? null)
const selectedNode = useSceneComputed(() => editor.getSelectedNode() ?? null)
const grids = computed<LayoutGrid[]>(() => selectedNode.value?.layoutGrids ?? [])
const patternOptions = computed(() => [
@ -41,21 +42,21 @@ function defaultGrid(): LayoutGrid {
function commit(next: LayoutGrid[], label: string) {
const node = selectedNode.value
if (!node) return
ctx.editor.updateNodeWithUndo(node.id, { layoutGrids: next }, label)
editor.updateNodeWithUndo(node.id, { layoutGrids: next }, label)
}
function add() {
commit([...grids.value, defaultGrid()], 'Add layout grid')
commit([...grids.value, defaultGrid()], 'Add layout guide')
}
function remove(index: number) {
commit(
grids.value.filter((_, i) => i !== index),
'Remove layout grid'
'Remove layout guide'
)
}
function patch(index: number, changes: Partial<LayoutGrid>, label = 'Edit layout grid') {
function patch(index: number, changes: Partial<LayoutGrid>, label = 'Edit layout guide') {
commit(
grids.value.map((grid, i) => (i === index ? { ...grid, ...changes } : grid)),
label
@ -74,6 +75,7 @@ function isGrid(grid: LayoutGrid): boolean {
<template>
<PanelSection :label="panels.layoutGrids" :empty="grids.length === 0">
<SharedStyleField kind="grid" :label="panels.gridStyle" />
<template #actions>
<IconButton :label="panels.addLayoutGrid" @click="add">
<icon-lucide-plus class="size-3.5" />
@ -136,7 +138,7 @@ function isGrid(grid: LayoutGrid): boolean {
<IconButton
:label="panels.toggleVisibility"
:active="grid.visible === false"
@click="patch(index, { visible: grid.visible === false }, 'Toggle layout grid')"
@click="patch(index, { visible: grid.visible === false }, 'Toggle layout guide')"
>
<icon-lucide-eye-off v-if="grid.visible === false" class="size-3.5" />
<icon-lucide-eye v-else class="size-3.5" />

View file

@ -5,12 +5,10 @@ import AutoLayoutControls from '@/components/properties/LayoutSection/AutoLayout
import ClipContentControl from '@/components/properties/LayoutSection/ClipContentControl.vue'
import FlexControls from '@/components/properties/LayoutSection/FlexControls.vue'
import GridControls from '@/components/properties/LayoutSection/GridControls.vue'
import LayoutGridSection from '@/components/properties/LayoutSection/LayoutGridSection.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PaddingControls from '@/components/properties/LayoutSection/PaddingControls.vue'
import SizeControls from '@/components/properties/LayoutSection/size/SizeControls.vue'
import TextResizingControl from '@/components/properties/LayoutSection/TextResizingControl.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
const { panels } = useI18n()
@ -21,45 +19,37 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
<template>
<LayoutControlsRoot v-slot="ctx">
<template v-if="ctx.node">
<PanelSection :label="panels.layout">
<SharedStyleField kind="grid" :label="panels.gridStyle" />
<PanelSection :label="ctx.node.layoutMode === 'NONE' ? panels.layout : panels.autoLayout">
<template v-if="CONTAINER_TYPES.includes(ctx.node.type)" #actions>
<IconButton
v-if="ctx.node.layoutMode === 'NONE'"
:label="panels.addAutoLayout"
@click="ctx.editor.setLayoutMode(ctx.node.id, 'VERTICAL')"
>
<icon-lucide-plus class="size-3.5" />
</IconButton>
<IconButton
v-else
:label="panels.removeAutoLayout"
@click="ctx.editor.setLayoutMode(ctx.node.id, 'NONE')"
>
<icon-lucide-minus class="size-3.5" />
</IconButton>
</template>
<AutoLayoutControls v-if="CONTAINER_TYPES.includes(ctx.node.type)" />
<TextResizingControl v-if="ctx.node.type === 'TEXT'" />
<SizeControls />
<template v-if="CONTAINER_TYPES.includes(ctx.node.type) && ctx.node.layoutMode !== 'NONE'">
<FlexControls v-if="ctx.isFlex" />
<template v-if="ctx.isGrid">
<GridControls />
<PaddingControls />
<ClipContentControl />
</template>
</template>
</PanelSection>
<template v-if="CONTAINER_TYPES.includes(ctx.node.type)">
<PanelSection :label="panels.autoLayout">
<template #actions>
<IconButton
v-if="ctx.node.layoutMode === 'NONE'"
:label="panels.addAutoLayout"
@click="ctx.editor.setLayoutMode(ctx.node.id, 'VERTICAL')"
>
<icon-lucide-plus class="size-3.5" />
</IconButton>
<IconButton
v-else
:label="panels.removeAutoLayout"
@click="ctx.editor.setLayoutMode(ctx.node.id, 'NONE')"
>
<icon-lucide-minus class="size-3.5" />
</IconButton>
</template>
<AutoLayoutControls />
<template v-if="ctx.node.layoutMode !== 'NONE'">
<FlexControls v-if="ctx.isFlex" />
<template v-if="ctx.isGrid">
<GridControls />
<PaddingControls />
<ClipContentControl />
</template>
</template>
</PanelSection>
<LayoutGridSection />
</template>
</template>
</LayoutControlsRoot>
</template>

View file

@ -50,7 +50,8 @@ test('Shift+A wraps selection in auto-layout frame', async () => {
expect(expectDefined(node, 'node').childIds.length).toBe(2)
frameId = expectDefined(node, 'node').id
await expect(propertySection(page, 'Layout')).toHaveScreenshot('layout-size-controls.png')
await expect(propertySection(page, 'Auto layout')).toHaveScreenshot('layout-size-controls.png')
await expect(propertySection(page, 'Layout')).toHaveCount(0)
canvas.assertNoErrors()
})
@ -158,7 +159,7 @@ test('padding controls set horizontal and vertical padding pairs', async () => {
test('size dropdown adds and removes min width', async () => {
await selectFrame()
const layout = propertySection(page, 'Layout')
const layout = propertySection(page, 'Auto layout')
await propertyField(page, 'width').getByRole('combobox', { name: 'Width' }).click()
await page.getByRole('option', { name: 'Add min width' }).click()
await canvas.waitForRender()
@ -274,5 +275,7 @@ test('remove auto-layout sets layoutMode to NONE', async () => {
const frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').layoutMode).toBe('NONE')
await expect(propertySection(page, 'Layout')).toBeVisible()
await expect(propertySection(page, 'Auto layout')).toHaveCount(0)
canvas.assertNoErrors()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -114,7 +114,7 @@ test('applies fill, stroke, effect, and grid styles from local definitions', asy
await chooseStyle('Fill', 'Fill style', 'Brand/Primary')
await chooseStyle('Stroke', 'Stroke style', 'Brand/Primary')
await chooseStyle('Effects', 'Effect style', 'Effects/Card')
await chooseStyle('Layout', 'Grid style', 'Grid/12 columns')
await chooseStyle('Layout guide', 'Grid style', 'Grid/12 columns')
expect(await targetStyles()).toMatchObject({
fillStyleId: '1:100',