fix(ui): expose auto-layout flow choices
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@
|
|||
"multiplayerCursors": "Cursor anderer Teilnehmer",
|
||||
"direction": "Richtung",
|
||||
"flow": "Fluss",
|
||||
"freeform": "Freiform",
|
||||
"dimensions": "Abmessungen",
|
||||
"layoutHorizontal": "Horizontales Layout",
|
||||
"layoutVertical": "Vertikales Layout",
|
||||
"layoutGrid": "Rasterlayout",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -142,6 +142,8 @@
|
|||
"multiplayerCursors": "他の参加者のカーソル",
|
||||
"direction": "方向",
|
||||
"flow": "フロー",
|
||||
"freeform": "フリーフォーム",
|
||||
"dimensions": "寸法",
|
||||
"layoutHorizontal": "横方向レイアウト",
|
||||
"layoutVertical": "縦方向レイアウト",
|
||||
"layoutGrid": "グリッドレイアウト",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@
|
|||
"multiplayerCursors": "Курсоры участников",
|
||||
"direction": "Направление",
|
||||
"flow": "Поток",
|
||||
"freeform": "Свободная",
|
||||
"dimensions": "Размеры",
|
||||
"layoutHorizontal": "Горизонтальная раскладка",
|
||||
"layoutVertical": "Вертикальная раскладка",
|
||||
"layoutGrid": "Сетка",
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@
|
|||
"multiplayerCursors": "协作光标",
|
||||
"direction": "方向",
|
||||
"flow": "流向",
|
||||
"freeform": "自由布局",
|
||||
"dimensions": "尺寸",
|
||||
"layoutHorizontal": "水平布局",
|
||||
"layoutVertical": "垂直布局",
|
||||
"layoutGrid": "网格布局",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="[
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
|
@ -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()
|
||||
|
|
|
|||