fix(ui): expose auto-layout flow choices

This commit is contained in:
Danila Poyarkov 2026-07-26 11:15:57 +03:00
parent 80750e65fc
commit c9cadf6fdd
21 changed files with 103 additions and 51 deletions

View file

@ -25,7 +25,7 @@
### Changed
- Group sizing and auto-layout controls into one contextual Design panel section, and use Figma’s current Layout guide terminology.
- Choose Freeform, vertical, horizontal, or grid flow directly from the contextual Layout section, with sizing grouped alongside it and 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

@ -128,6 +128,8 @@
"multiplayerCursors": "Cursor anderer Teilnehmer",
"direction": "Richtung",
"flow": "Fluss",
"freeform": "Freiform",
"dimensions": "Abmessungen",
"layoutHorizontal": "Horizontales Layout",
"layoutVertical": "Vertikales Layout",
"layoutGrid": "Rasterlayout",

View file

@ -142,6 +142,8 @@
"multiplayerCursors": "Cursores de otros participantes",
"direction": "Dirección",
"flow": "Flujo",
"freeform": "Forma libre",
"dimensions": "Dimensiones",
"layoutHorizontal": "Diseño horizontal",
"layoutVertical": "Diseño vertical",
"layoutGrid": "Diseño de cuadrícula",

View file

@ -128,6 +128,8 @@
"multiplayerCursors": "Curseurs des participants",
"direction": "Direction",
"flow": "Flux",
"freeform": "Forme libre",
"dimensions": "Dimensions",
"layoutHorizontal": "Disposition horizontale",
"layoutVertical": "Disposition verticale",
"layoutGrid": "Disposition en grille",

View file

@ -128,6 +128,8 @@
"multiplayerCursors": "Cursori degli altri partecipanti",
"direction": "Direzione",
"flow": "Flusso",
"freeform": "Forma libera",
"dimensions": "Dimensioni",
"layoutHorizontal": "Layout orizzontale",
"layoutVertical": "Layout verticale",
"layoutGrid": "Layout a griglia",

View file

@ -142,6 +142,8 @@
"multiplayerCursors": "他の参加者のカーソル",
"direction": "方向",
"flow": "フロー",
"freeform": "フリーフォーム",
"dimensions": "寸法",
"layoutHorizontal": "横方向レイアウト",
"layoutVertical": "縦方向レイアウト",
"layoutGrid": "グリッドレイアウト",

View file

@ -128,6 +128,8 @@
"multiplayerCursors": "Kursory innych uczestników",
"direction": "Kierunek",
"flow": "Przepływ",
"freeform": "Swobodny",
"dimensions": "Wymiary",
"layoutHorizontal": "Układ poziomy",
"layoutVertical": "Układ pionowy",
"layoutGrid": "Układ siatki",

View file

@ -128,6 +128,8 @@
"multiplayerCursors": "Курсоры участников",
"direction": "Направление",
"flow": "Поток",
"freeform": "Свободная",
"dimensions": "Размеры",
"layoutHorizontal": "Горизонтальная раскладка",
"layoutVertical": "Вертикальная раскладка",
"layoutGrid": "Сетка",

View file

@ -128,6 +128,8 @@
"multiplayerCursors": "协作光标",
"direction": "方向",
"flow": "流向",
"freeform": "自由布局",
"dimensions": "尺寸",
"layoutHorizontal": "水平布局",
"layoutVertical": "垂直布局",
"layoutGrid": "网格布局",

View file

@ -231,6 +231,8 @@ export const panelMessageDefaults = {
multiplayerCursors: 'Multiplayer cursors',
direction: 'Direction',
flow: 'Flow',
freeform: 'Freeform',
dimensions: 'Dimensions',
layoutHorizontal: 'Horizontal layout',
layoutVertical: 'Vertical layout',
layoutGrid: 'Grid layout',

View file

@ -4,46 +4,56 @@ import { computed } from 'vue'
import { useI18n, useLayoutControlsContext } from '@open-pencil/vue'
import IconButton from '@/components/ui/IconButton.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import Tip from '@/components/ui/Tip.vue'
import type { LayoutMode } from '@open-pencil/scene-graph'
const ctx = useLayoutControlsContext()
const { panels } = useI18n()
const layoutModes = computed<Array<{ mode: LayoutMode; label: string }>>(() => [
{ mode: 'HORIZONTAL', label: panels.value.layoutHorizontal },
{ mode: 'VERTICAL', label: panels.value.layoutVertical },
{ mode: 'GRID', label: panels.value.layoutGrid }
const layoutModes = computed<Array<{ value: LayoutMode; label: string }>>(() => [
{ value: 'NONE', label: panels.value.freeform },
{ value: 'VERTICAL', label: panels.value.layoutVertical },
{ value: 'HORIZONTAL', label: panels.value.layoutHorizontal },
{ value: 'GRID', label: panels.value.layoutGrid }
])
function setLayoutMode(mode: string) {
ctx.editor.setLayoutMode(ctx.node.id, mode as LayoutMode)
}
</script>
<template>
<div
v-if="ctx.node.layoutMode !== 'NONE'"
class="flex items-center gap-1"
role="toolbar"
:aria-label="panels.flow"
>
<IconButton
v-for="direction in layoutModes"
:key="direction.mode"
:label="direction.label"
size="md"
:active="direction.mode === 'GRID' ? ctx.isGrid : ctx.node.layoutMode === direction.mode"
@click="ctx.editor.setLayoutMode(ctx.node.id, direction.mode)"
>
<icon-lucide-arrow-right v-if="direction.mode === 'HORIZONTAL'" class="size-3.5" />
<icon-lucide-arrow-down v-else-if="direction.mode === 'VERTICAL'" class="size-3.5" />
<icon-lucide-layout-grid v-else class="size-3.5" />
</IconButton>
<IconButton
v-if="ctx.isFlex"
:label="panels.layoutWrap"
size="md"
:active="ctx.node.layoutWrap === 'WRAP'"
@click="ctx.updateProp('layoutWrap', ctx.node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>
<icon-lucide-wrap-text class="size-3.5" />
</IconButton>
<div>
<label class="mb-1 block text-[11px] text-muted">{{ panels.flow }}</label>
<div class="flex items-center gap-1.5">
<SegmentedControl
:model-value="ctx.node.layoutMode"
:options="layoutModes"
:label="panels.flow"
:ui="{ root: 'flex min-w-0 flex-1' }"
@change="setLayoutMode"
>
<template #option="{ option }">
<Tip :label="option.label" class="flex items-center justify-center">
<icon-lucide-move v-if="option.value === 'NONE'" class="size-3.5" />
<icon-lucide-rows-2 v-else-if="option.value === 'VERTICAL'" class="size-3.5" />
<icon-lucide-columns-2 v-else-if="option.value === 'HORIZONTAL'" class="size-3.5" />
<icon-lucide-layout-grid v-else class="size-3.5" />
</Tip>
</template>
</SegmentedControl>
<IconButton
v-if="ctx.isFlex"
:label="panels.layoutWrap"
size="md"
:active="ctx.node.layoutWrap === 'WRAP'"
@click="ctx.updateProp('layoutWrap', ctx.node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>
<icon-lucide-wrap-text class="size-3.5" />
</IconButton>
</div>
</div>
</template>

View file

@ -52,7 +52,7 @@ function alignmentCellClass(primary: LayoutAlign, counter: string) {
<template>
<div class="mt-2">
<label class="mb-1 block text-[11px] text-muted">{{ panels.flow }}</label>
<label class="mb-1 block text-[11px] text-muted">{{ panels.direction }}</label>
<AppSelect
:model-value="ctx.layoutDirection"
:options="[

View file

@ -22,24 +22,28 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
<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')"
:label="ctx.node.layoutMode === 'NONE' ? panels.addAutoLayout : panels.removeAutoLayout"
size="md"
:active="ctx.node.layoutMode !== 'NONE'"
class="data-[state=on]:bg-accent/15"
@click="
ctx.editor.setLayoutMode(
ctx.node.id,
ctx.node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE'
)
"
>
<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" />
<icon-lucide-layout-panel-top class="size-3.5" />
</IconButton>
</template>
<AutoLayoutControls v-if="CONTAINER_TYPES.includes(ctx.node.type)" />
<div class="mt-2 mb-1 text-[11px] text-muted">{{ panels.dimensions }}</div>
<TextResizingControl v-if="ctx.node.type === 'TEXT'" />
<SizeControls />
<ClipContentControl
v-if="CONTAINER_TYPES.includes(ctx.node.type) && ctx.node.layoutMode === 'NONE'"
/>
<template v-if="CONTAINER_TYPES.includes(ctx.node.type) && ctx.node.layoutMode !== 'NONE'">
<FlexControls v-if="ctx.isFlex" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -265,17 +265,36 @@ test('alignment grid center sets CENTER alignment', async () => {
canvas.assertNoErrors()
})
test('remove auto-layout sets layoutMode to NONE', async () => {
test('Freeform disables auto layout and a flow option restores it', async () => {
await selectFrame()
await propertySection(page, 'Auto layout')
.getByRole('button', { name: 'Remove auto layout' })
.click()
await propertySection(page, 'Auto layout').getByRole('button', { name: 'Freeform' }).click()
await canvas.waitForRender()
const frame = await getNodeById(page, frameId)
let frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').layoutMode).toBe('NONE')
await expect(propertySection(page, 'Layout')).toBeVisible()
const layout = propertySection(page, 'Layout')
await expect(layout).toBeVisible()
await expect(layout.getByRole('button', { name: 'Add auto layout' })).toHaveAttribute(
'data-state',
'off'
)
await expect(layout.getByText('Flow', { exact: true })).toBeVisible()
await expect(layout.getByRole('checkbox', { name: 'Clip content' })).toBeVisible()
await expect(layout.getByRole('button', { name: 'Freeform' })).toHaveAttribute('data-state', 'on')
await expect(propertySection(page, 'Auto layout')).toHaveCount(0)
await expect(layout).toHaveScreenshot('layout-freeform-controls.png')
await layout.getByRole('button', { name: 'Vertical layout' }).click()
await canvas.waitForRender()
frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').layoutMode).toBe('VERTICAL')
const autoLayout = propertySection(page, 'Auto layout')
await expect(autoLayout).toBeVisible()
await expect(autoLayout.getByRole('button', { name: 'Remove auto layout' })).toHaveAttribute(
'data-state',
'on'
)
await expect(propertySection(page, 'Layout')).toHaveCount(0)
canvas.assertNoErrors()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -1,7 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertySection } from '#tests/helpers/properties'
import { propertyItems, propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
@ -128,6 +128,7 @@ test('applies fill, stroke, effect, and grid styles from local definitions', asy
})
test('manual paint edits detach the style and undo restores the reference', async () => {
await propertyItems(page, 'fills').first().hover()
await propertySection(page, 'Fill').getByRole('button', { name: 'Remove fill' }).click()
await canvas.waitForRender()
expect((await targetStyles())?.fillStyleId).toBeNull()